diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -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 diff --git a/.editorconfig b/.editorconfig index 5986da5..3bab7c4 100755 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..179cc1d --- /dev/null +++ b/AGENTS.md @@ -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! diff --git a/Classes/Backend/Controller/PageEditController.php b/Classes/Backend/Controller/PageEditController.php index 019d721..b109180 100644 --- a/Classes/Backend/Controller/PageEditController.php +++ b/Classes/Backend/Controller/PageEditController.php @@ -20,15 +20,22 @@ use TYPO3\CMS\Backend\Template\Components\Buttons\GenericButton; use TYPO3\CMS\Backend\Template\Components\Buttons\LanguageSelectorBuilder; use TYPO3\CMS\Backend\Template\Components\Buttons\LanguageSelectorMode; +use TYPO3\CMS\Backend\Template\Components\ComponentFactory; use TYPO3\CMS\Backend\Template\ModuleTemplate; use TYPO3\CMS\Backend\Template\ModuleTemplateFactory; use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Backend\View\BackendLayout\ContentFetcher; +use TYPO3\CMS\Backend\View\BackendLayoutView; +use TYPO3\CMS\Backend\View\Drawing\DrawingConfiguration; +use TYPO3\CMS\Backend\View\PageLayoutContext; +use TYPO3\CMS\Backend\View\PageViewMode; use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction; +use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry; use TYPO3\CMS\Core\Domain\Record; use TYPO3\CMS\Core\Domain\RecordFactory; use TYPO3\CMS\Core\Http\HtmlResponse; @@ -44,27 +51,29 @@ use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; use TYPO3\CMS\Core\Schema\TcaSchema; use TYPO3\CMS\Core\Schema\TcaSchemaFactory; -use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; -use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation; -use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; -use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode; -use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyRegistry; -use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; use TYPO3\CMS\Core\Site\Entity\Site; use TYPO3\CMS\Core\Site\Entity\SiteLanguage; use TYPO3\CMS\Core\Type\Bitmask\Permission; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Versioning\VersionState; -use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry; +use TYPO3\CMS\VisualEditor\Backend\View\PageEditViewMode; +use TYPO3\CMS\VisualEditor\Service\AllowedOriginService; +use function array_column; +use function array_filter; +use function array_key_exists; +use function array_key_first; use function array_map; +use function array_unique; use function array_values; use function assert; use function count; use function in_array; use function is_array; +use function json_encode; use function sprintf; +use const JSON_THROW_ON_ERROR; use const JSON_UNESCAPED_SLASHES; /** @@ -93,12 +102,13 @@ public function __construct( private readonly RecordFactory $recordFactory, private readonly TcaSchemaFactory $tcaSchemaFactory, private readonly PackageManager $packageManager, - private readonly PolicyRegistry $policyRegistry, private readonly Typo3Version $typo3Version, private readonly ConnectionPool $connectionPool, private readonly AssetCollector $assetCollector, private readonly Context $context, private readonly PageDoktypeRegistry $pageDoktypeRegistry, + private readonly AllowedOriginService $allowedOriginService, + private readonly BackendLayoutView $backendLayoutView, ) { } @@ -118,6 +128,7 @@ private function initialize(ServerRequestInterface $request): void $this->availableLanguages = $site->getAvailableLanguages($backendUser, false, $pageUid); $languages = $this->moduleData->get('languages') ?? [0]; $this->selectedLanguages = array_values(array_map(fn($languageUid): SiteLanguage => $site->getLanguageById((int)$languageUid), $languages)); + sort($this->selectedLanguages); $this->pageRenderer->addInlineLanguageLabelFile('EXT:visual_editor/Resources/Private/Language/locallang.xlf'); $this->schema = $this->tcaSchemaFactory->get('pages'); @@ -136,18 +147,35 @@ private function initialize(ServerRequestInterface $request): void throw new InvalidArgumentException('Page record is of type "folder" and cannot be edited with the Visual Editor', 5965019514); } - if ($this->typo3Version->getMajorVersion() >= 14 && !$this->pageDoktypeRegistry->isPageViewable((int) $record->getRecordType(), $pageUid)) { + if ($this->typo3Version->getMajorVersion() >= 14 && !$this->pageDoktypeRegistry->isPageViewable((int)$record->getRecordType(), $pageUid)) { throw new InvalidArgumentException('Page record is not viewable and cannot be edited with the Visual Editor', 5965019515); } $this->pageRecord = $record; - $localizedPageRecord = $this->getLocalizedPageRecord($this->selectedLanguages[0]->getLanguageId()); + foreach ($this->selectedLanguages as $key => $language) { + if ($language->getLanguageId() === 0) { + continue; + } + + $localizedPageRecord = $this->getLocalizedPageRecord($language->getLanguageId()); + if ($localizedPageRecord === null) { + // if no translation is found for that language, we remove it from the list. + unset($this->selectedLanguages[$key]); + } + } + + // TODO filter out by origin, and only allow the first origin (no cross origin) - if (!$localizedPageRecord) { - // if no translation is found for the selected langauge, we reset the langauge to the default language + $this->selectedLanguages = array_values($this->selectedLanguages); + + if (!$this->selectedLanguages) { + // if no selectedLanguages are left, we set the langauge to the default language $this->selectedLanguages = [$site->getDefaultLanguage()]; } + + $this->applyViewModeToSelectedLanguages($site); + $this->updateModuleData(); } public function __invoke(ServerRequestInterface $request): ResponseInterface @@ -183,11 +211,52 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface $view->getDocHeaderComponent()->setPageBreadcrumb($this->pageRecord->getRawRecord()->toArray()); } - $siteLanguage = $this->selectedLanguages[0]; - $iframeUrl = $this->iframeUrl($request, $siteLanguage); + $langauges = []; + foreach ($this->selectedLanguages as $siteLanguage) { + $buttonBar = GeneralUtility::makeInstance(ButtonBar::class); + $iframeUrl = $this->iframeUrl($request, $siteLanguage); + $isSameOrigin = $this->isSameOrigin($iframeUrl, $request); + $langauges[] = [ + 'id' => $siteLanguage->getLanguageId(), + 'flagIdentifier' => $siteLanguage->getFlagIdentifier(), + 'title' => $siteLanguage->getTitle(), + 'sameOrigin' => $isSameOrigin, + 'iframeUrl' => $iframeUrl, + 'backendUrl' => $request->getUri()->withHost($iframeUrl->getHost())->withScheme($iframeUrl->getScheme())->withPort($iframeUrl->getPort()), + 'iframeTitle' => sprintf( + '%s: %s', + $this->getLanguageService()->sL('LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:edit_page'), + (string)$this->pageRecord->get('title'), + ), + 'viewButton' => $this->makeViewButton($buttonBar, $request, $siteLanguage)?->render(), + 'editButton' => $this->makeEditButton($buttonBar, $request, $siteLanguage)?->render(), + 'translateButton' => $this->makeTranslateButton($siteLanguage, $request)?->render(), + ]; + } + + if (array_sum(array_column($langauges, 'sameOrigin')) === 0) { + $this->forceSameOrigin($langauges[0]['iframeUrl']); + } + + $allowedOrigin = $this->allowedOriginService->getAllowedOrigins(); + $veInfo = [ + 'allowedOrigins' => $allowedOrigin, + ]; + + $this->assetCollector->addInlineJavaScript( + 'veLangInfo', + 'window.TYPO3 = window.TYPO3 || {};window.veInfo = ' . json_encode($veInfo, JSON_THROW_ON_ERROR) . ';', + [ + 'type' => 'text/javascript', + ], + [ + 'useNonce' => true, + ], + ); + $view->assignMultiple([ 'pageId' => $this->pageRecord->getUid(), - 'iframeSrc' => $iframeUrl, + 'languages' => $langauges, 'iframeTitle' => sprintf( '%s: %s', $this->getLanguageService()->sL('LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:edit_page'), @@ -196,16 +265,56 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface ]); $this->makeButtons($view, $request); + $this->makeViewModeSelection($view, $request); $this->makeLanguageMenu($view, $request); +// foreach($iframeUrls as $iframeUrl) { +// if ($iframeUrl->getScheme() !== '' && $iframeUrl->getHost() !== '') { +// // temporarily(!) extend the CSP `frame-src` directive with the URL to be shown in the ` +
+ + + + +
+ + {language.title} + {language.viewButton -> f:format.raw()} + {language.editButton -> f:format.raw()} + {language.translateButton -> f:format.raw()} + {language.translateButton -> f:format.raw()} +
+
+
+ +
+
+
+ + + + + + +
+

+ +

+

+ +

+

+ +

+ + + +
+
+
+
+
diff --git a/Resources/Public/Css/editable.css b/Resources/Public/Css/editable.css index 1f86d97..b5451f8 100644 --- a/Resources/Public/Css/editable.css +++ b/Resources/Public/Css/editable.css @@ -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; diff --git a/Resources/Public/JavaScript/Backend/aggregate-editor-states.js b/Resources/Public/JavaScript/Backend/aggregate-editor-states.js new file mode 100644 index 0000000..929e67f --- /dev/null +++ b/Resources/Public/JavaScript/Backend/aggregate-editor-states.js @@ -0,0 +1,74 @@ +/** + * Checks whether a value is a plain object. + * + * Plain objects are recursively merged. Arrays, dates, functions, and other + * object types are treated as regular values and overwritten. + * + * @param {*} value + * The value to check. + * + * @returns {boolean} + * Whether the value is a plain object. + */ +function isPlainObject(value) { + return ( + value !== null + && typeof value === 'object' + && !Array.isArray(value) + && Object.prototype.toString.call(value) === '[object Object]' + ); +} + +/** + * Recursively merges two objects. + * + * Values from the higher priority object overwrite values from the lower + * priority object. If both values are plain objects, they are merged + * recursively. + * + * This function does not mutate the given objects. + * + * @template T + * @template U + * + * @param {T} lowerPriority + * The base object whose values are used by default. + * + * @param {U} higherPriority + * The object whose values take precedence. + * + * @returns {T & U} + * A new object containing the recursively merged values. + */ +function deepMerge(lowerPriority, higherPriority) { + const result = {...lowerPriority}; + + for (const key of Object.keys(higherPriority)) { + const lowerValue = result[key]; + const higherValue = higherPriority[key]; + + if (isPlainObject(lowerValue) && isPlainObject(higherValue)) { + result[key] = deepMerge(lowerValue, higherValue); + } else { + result[key] = higherValue; + } + } + + return result; +} + +/** + * + * @param editorStates {Map} + * @return {{data: Object, cmdArray: Object[], invalidFields: Object, count: number, invalidCount: number}} + */ +export function aggregateEditorStates(editorStates) { + const valuesOnlySortedByKey = [...editorStates.keys()].sort((a, b) => a - b).map(key => editorStates.get(key)); + return valuesOnlySortedByKey.reduce((aggregatedState, {data = {}, cmdArray = [], invalidFields = {}, count = 0, invalidCount = 0}) => ({ + data: deepMerge(aggregatedState.data, data), + cmdArray: [...aggregatedState.cmdArray, ...cmdArray], + invalidFields: deepMerge(aggregatedState.invalidFields, invalidFields), + count: aggregatedState.count + count, + invalidCount: aggregatedState.invalidCount + invalidCount, + }), {data: {}, cmdArray: [], invalidFields: {}, count: 0, invalidCount: 0}); +} diff --git a/Resources/Public/JavaScript/Backend/aggregate-editor-states.test.js b/Resources/Public/JavaScript/Backend/aggregate-editor-states.test.js new file mode 100644 index 0000000..58cd979 --- /dev/null +++ b/Resources/Public/JavaScript/Backend/aggregate-editor-states.test.js @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {aggregateEditorStates} from './aggregate-editor-states.js'; + +test('aggregates editor states from all iframes', () => { + const firstIframe = {}; + const secondIframe = {}; + const editorStates = new Map([ + [firstIframe, { + data: {tt_content: {1: {bodytext: 'Changed'}}}, + cmdArray: [{command: 'copy', table: 'tt_content', uid: 1}], + invalidFields: {bodytext: ['Required']}, + count: 2, + invalidCount: 1, + }], + [secondIframe, { + data: {pages: {2: {title: 'Updated'}}}, + cmdArray: [{command: 'move', table: 'pages', uid: 2}], + invalidFields: {title: ['Too short']}, + count: 3, + invalidCount: 2, + }], + ]); + + assert.deepEqual(aggregateEditorStates(editorStates), { + data: { + tt_content: {1: {bodytext: 'Changed'}}, + pages: {2: {title: 'Updated'}}, + }, + cmdArray: [ + {command: 'copy', table: 'tt_content', uid: 1}, + {command: 'move', table: 'pages', uid: 2}, + ], + invalidFields: { + bodytext: ['Required'], + title: ['Too short'], + }, + count: 5, + invalidCount: 3, + }); +}); + +test('uses the latest state for an iframe', () => { + const iframe = {}; + const editorStates = new Map([ + [iframe, { + data: {tt_content: {1: {bodytext: 'Old'}}}, + cmdArray: [{command: 'copy', table: 'tt_content', uid: 1}], + invalidFields: {bodytext: ['Required']}, + count: 2, + invalidCount: 1, + }], + ]); + + editorStates.set(iframe, { + data: {}, + cmdArray: [], + invalidFields: {}, + count: 0, + invalidCount: 0, + }); + + assert.deepEqual(aggregateEditorStates(editorStates), { + data: {}, + cmdArray: [], + invalidFields: {}, + count: 0, + invalidCount: 0, + }); +}); + +test('deep merges nested editor state data and invalid fields', () => { + const firstIframe = {}; + const secondIframe = {}; + const editorStates = new Map([ + [firstIframe, { + data: { + tt_content: { + 1: { + header: 'Original header', + bodytext: 'Original body', + }, + }, + }, + invalidFields: { + tt_content: { + 1: { + header: ['Required'], + }, + }, + }, + }], + [secondIframe, { + data: { + tt_content: { + 1: { + bodytext: 'Updated body', + }, + }, + }, + invalidFields: { + tt_content: { + 1: { + bodytext: ['Too short'], + }, + }, + }, + }], + ]); + + assert.deepEqual(aggregateEditorStates(editorStates), { + data: { + tt_content: { + 1: { + header: 'Original header', + bodytext: 'Updated body', + }, + }, + }, + cmdArray: [], + invalidFields: { + tt_content: { + 1: { + header: ['Required'], + bodytext: ['Too short'], + }, + }, + }, + count: 0, + invalidCount: 0, + }); +}); diff --git a/Resources/Public/JavaScript/Backend/components/ve-backend-save-button.js b/Resources/Public/JavaScript/Backend/components/ve-backend-save-button.js index 535f86a..df784ed 100644 --- a/Resources/Public/JavaScript/Backend/components/ve-backend-save-button.js +++ b/Resources/Public/JavaScript/Backend/components/ve-backend-save-button.js @@ -3,11 +3,12 @@ import {lll} from '@typo3/core/lit-helper.js'; import {onMessage, sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging'; import {useDataHandler} from '@typo3/visual-editor/Backend/use-data-handler'; import {reloadAllChildFrames} from '@typo3/visual-editor/Backend/reload-all-child-frames'; +import {aggregateEditorStates} from '@typo3/visual-editor/Backend/aggregate-editor-states'; /** - * @type {{data: Object, cmdArray: Object[], invalidFields: Object, count: number, invalidCount: number}} + * @type {Map} */ -let lastInfo = null; +const editorStates = new Map(); /** * @extends {HTMLElement} @@ -42,9 +43,7 @@ export class VeBackendSaveButton extends LitElement { this.onKeydown = this.#onKeydown.bind(this); this.disposeUpdateEditorStateListener = null; this.disposeDoSaveListener = null; - if (lastInfo) { - this.onUpdateEditorState(lastInfo); - } + this.updateAggregatedEditorState(); } connectedCallback() { @@ -95,10 +94,20 @@ export class VeBackendSaveButton extends LitElement { `; } - onUpdateEditorState(info) { - lastInfo = info; - this.count = info.count; - this.invalidCount = info.invalidCount; + /** + * + * @param info {{count: number, invalidCount: number}} + * @param fromLanguageId {number} + */ + onUpdateEditorState(info, fromLanguageId) { + editorStates.set(fromLanguageId, info); + this.updateAggregatedEditorState(); + } + + updateAggregatedEditorState() { + const {count, invalidCount} = aggregateEditorStates(editorStates); + this.count = count; + this.invalidCount = invalidCount; } async doSave() { @@ -106,10 +115,15 @@ export class VeBackendSaveButton extends LitElement { return; } - const count = lastInfo.count; - const invalidCount = lastInfo.invalidCount; + const count = this.count; + const invalidCount = this.invalidCount; if (invalidCount > 0) { - sendMessage('focusFirstInvalidField'); + for (const [languageId, editorState] of editorStates.entries()) { + if (editorState.invalidCount) { + sendMessage('focusFirstInvalidField', {languageId}); + break; + } + } return; } @@ -123,18 +137,20 @@ export class VeBackendSaveButton extends LitElement { return; } - const updatePageTree = 'pages' in lastInfo.data; + const mergedInfo = aggregateEditorStates(editorStates); + + const updatePageTree = 'pages' in mergedInfo.data; try { this.saving = true; - const saveOk = await useDataHandler(lastInfo.data, lastInfo.cmdArray); - requestAnimationFrame(() => { - sendMessage('saveEnded'); - }); + const saveOk = await useDataHandler(mergedInfo.data, mergedInfo.cmdArray); + sendMessage('saveEnded'); if (!saveOk) { reloadAllChildFrames(); } } finally { + editorStates.clear(); + this.updateAggregatedEditorState(); this.saving = false; } diff --git a/Resources/Public/JavaScript/Backend/iframe-loading-indicator.js b/Resources/Public/JavaScript/Backend/iframe-loading-indicator.js new file mode 100644 index 0000000..e2fc365 --- /dev/null +++ b/Resources/Public/JavaScript/Backend/iframe-loading-indicator.js @@ -0,0 +1,106 @@ +import Backend from '@typo3/backend/viewport.js'; + +const defaultShowDelay = 150; +const defaultTimeout = 30000; +const loadingIframes = new Set(); +const watchedIframes = new WeakSet(); +let showTimer = null; +let timeoutTimer = null; +let isStarted = false; + +export function initializeIframeLoadingIndicator() { + for (const iframe of document.querySelectorAll('iframe')) { + watchIframe(iframe); + + if (!isIframeLoaded(iframe)) { + markLoading(iframe); + } + } +} + +/** + * @param {HTMLIFrameElement|null} iframe + */ +export function markLoading(iframe) { + if (!iframe) { + return; + } + watchIframe(iframe); + loadingIframes.add(iframe); + scheduleStart(); + scheduleTimeout(); +} + +/** + * @param {HTMLIFrameElement} iframe + */ +function watchIframe(iframe) { + if (watchedIframes.has(iframe)) { + return; + } + watchedIframes.add(iframe); + iframe.addEventListener('load', () => { + loadingIframes.delete(iframe); + if (loadingIframes.size === 0) { + finish(); + } + }); +} + +/** + * @param {HTMLIFrameElement} iframe + * @return {boolean} + */ +function isIframeLoaded(iframe) { + try { + return iframe.contentDocument?.readyState === 'complete' + || iframe.contentWindow?.document?.readyState === 'complete'; + } catch { + return false; + } +} + +function scheduleStart() { + if (isStarted || showTimer !== null) { + return; + } + showTimer = setTimeout(() => { + showTimer = null; + if (loadingIframes.size === 0) { + return; + } + isStarted = true; + Backend.Loader.start(); + }, defaultShowDelay); +} + +function scheduleTimeout() { + if (timeoutTimer !== null) { + return; + } + timeoutTimer = setTimeout(() => { + timeoutTimer = null; + if (loadingIframes.size === 0) { + return; + } + console.warn('Visual Editor iframe loading indicator timed out.'); + loadingIframes.clear(); + finish(); + }, defaultTimeout); +} + +function finish() { + if (showTimer !== null) { + clearTimeout(showTimer); + showTimer = null; + } + if (timeoutTimer !== null) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + if (!isStarted) { + return; + } + isStarted = false; + Backend.Loader.finish(); +} diff --git a/Resources/Public/JavaScript/Backend/index.js b/Resources/Public/JavaScript/Backend/index.js index 39215c5..807d876 100644 --- a/Resources/Public/JavaScript/Backend/index.js +++ b/Resources/Public/JavaScript/Backend/index.js @@ -1,5 +1,5 @@ import Modal from '@typo3/backend/modal.js'; -import {onMessage, stopListeningMessages} from '@typo3/visual-editor/Shared/iframe-messaging'; +import {onMessage, sendMessage, stopListeningMessages} from '@typo3/visual-editor/Shared/iframe-messaging'; import '@typo3/visual-editor/Backend/components/ve-auto-save-toggle'; import '@typo3/visual-editor/Backend/components/ve-backend-save-button'; import '@typo3/visual-editor/Backend/components/ve-spotlight-toggle'; @@ -8,8 +8,17 @@ import '@typo3/visual-editor/Backend/components/ve-show-hidden-toggle'; import {pageChanged} from '@typo3/visual-editor/Backend/page-changed'; import {initializePageTreeSaveState} from '@typo3/visual-editor/Backend/initialize-page-tree-save-state'; import {reloadAllChildFrames} from '@typo3/visual-editor/Backend/reload-all-child-frames'; +import {initializeIframeLoadingIndicator, markLoading} from '@typo3/visual-editor/Backend/iframe-loading-indicator'; + +// fix iframe state on reloads +// [...document.querySelectorAll('iframe')].map((iframe) => { +// if (iframe.contentWindow.location.href !== iframe.src) { +// iframe.contentWindow.location.replace(iframe.src); +// } +// }); initializePageTreeSaveState(); +initializeIframeLoadingIndicator(); /** * @param src {string} @@ -39,6 +48,7 @@ function openIframeModal(src, title = '', size = 'large', type = 'iframe') { onMessage('openModal', data => openIframeModal(data.src, data.title || '', data.size || undefined, data.type || undefined)); onMessage('reloadFrames', () => reloadAllChildFrames()); +onMessage('iframeLoadingStarted', (_data, fromLanguageId) => markLoading(document.querySelector(`iframe[data-language-id="${fromLanguageId}"]`))); onMessage('openInMiddleFrame', (href) => { const parsedHref = new URL(href, window.location.href); // keep the origin of the current window to avoid CORS issues in the Backend. @@ -48,4 +58,17 @@ onMessage('openInMiddleFrame', (href) => { window.location = parsedHref.href; }); -onMessage('pageChanged', data => pageChanged(data.pageId, data.languageId, data.routeArguments)); +onMessage('pageChanged', (data, fromLanguageId) => pageChanged(data.pageId, data.languageId, data.routeArguments, fromLanguageId)); + +const syncMessages = [ + ['scrollPositionChanged', 'syncScrollPosition'], + ['editableFieldFocusChanged', 'syncEditableFieldFocus'], + ['contentElementDeleted', 'syncContentElementDeleted'], + ['contentElementMoved', 'syncContentElementMoved'], +]; +syncMessages.forEach(([event, message]) => { + onMessage(event, (data, languageId) => sendMessage(message, { + ...data, + languageId, + }, 'iframe')); +}); diff --git a/Resources/Public/JavaScript/Backend/page-changed.js b/Resources/Public/JavaScript/Backend/page-changed.js index 36b8771..70d13c1 100644 --- a/Resources/Public/JavaScript/Backend/page-changed.js +++ b/Resources/Public/JavaScript/Backend/page-changed.js @@ -2,11 +2,12 @@ * @param pageId {number} * @param languageId {number} * @param routeArguments {Record} + * @param fromLanguageId {number} */ -export function pageChanged(pageId, languageId, routeArguments) { +export function pageChanged(pageId, languageId, routeArguments, fromLanguageId) { pageId = parseInt(pageId, 10); languageId = parseInt(languageId, 10); - + fromLanguageId = parseInt(fromLanguageId, 10); if (isNaN(pageId) || pageId <= 0) { console.error('pageChanged: invalid pageId', pageId); return; @@ -16,10 +17,11 @@ export function pageChanged(pageId, languageId, routeArguments) { languageId = 0; } + const oldUrl = window.location.href; const newUrl = updateUrlOfWindow(window, pageId, languageId, routeArguments); - - newUrl.searchParams.set('languages[0]', languageId); - loadModuleDocHeader(newUrl); + if (oldUrl === newUrl.href && languageId === fromLanguageId) { + return; + } // set href of refresh button to new URL document.querySelector('[data-identifier="actions-refresh"]').parentNode.href = newUrl.toString(); @@ -27,6 +29,11 @@ export function pageChanged(pageId, languageId, routeArguments) { updateUrlOfWindow(window.top, pageId, languageId, routeArguments); ModuleStateStorage.update('web', pageId); + + updateModuleState(newUrl); + + // reload all other iframes. + // reloadAllChildFrames(fromLanguageId); // TODO prevent infinite reload loop } /** @@ -34,17 +41,27 @@ export function pageChanged(pageId, languageId, routeArguments) { * @param pageId {number} * @param languageId {number} * @param routeArguments {Record} - * @return {module:url.URL} + * @return {URL} */ -function updateUrlOfWindow(windowObject, pageId, languageId, routeArguments) { +export function updateUrlOfWindow(windowObject, pageId, languageId, routeArguments) { const newUrl = new URL(windowObject.location.href); - for (const param of newUrl.searchParams.keys()) { - if (param.startsWith('id') || param.startsWith('languages[') || param.startsWith('params[')) { + const currentLanguages = [...document.querySelectorAll('.js-visual-editor-language')].map(languageElement => parseInt(languageElement.dataset.languageId, 10)); + for (const param of [...newUrl.searchParams.keys()]) { + if (['id', 'viewMode'].includes(param) || param.startsWith('languages[') || param.startsWith('params[')) { newUrl.searchParams.delete(param); } } newUrl.searchParams.set('id', pageId); - if (languageId) { + + if (currentLanguages.length > 1) { + newUrl.searchParams.set('viewMode', '2'); // force multilanguage + if (!currentLanguages.includes(parseInt(languageId, 10))) { + currentLanguages.push(parseInt(languageId, 10)); + } + currentLanguages + .sort() + .forEach((value, index) => newUrl.searchParams.append(`languages[${index}]`, value)); + } else { newUrl.searchParams.set('languages[0]', languageId); } @@ -63,7 +80,7 @@ let abortController = new AbortController(); /** * @param newUrl {URL} */ -async function loadModuleDocHeader(newUrl) { +async function updateModuleState(newUrl) { abortController.abort(); abortController = new AbortController(); let response; @@ -84,18 +101,41 @@ async function loadModuleDocHeader(newUrl) { const html = await response.text(); const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const newDocHeaders = doc.querySelectorAll('.module-docheader'); - if (!newDocHeaders.length) { + const newStateDocument = parser.parseFromString(html, 'text/html'); + if (newStateDocument.querySelectorAll('iframe').length + !== document.querySelectorAll('iframe').length + ) { + // iframe count mismatch, either we got a new langauge or some were removed. + // force reload without cache (true only works in firefox): + window.location.reload(true); + return; + } + if (document.querySelectorAll('iframe').length > 1) { + // only compare iframe language if in multilanguage mode, otherwise the iframe is always the same and we don't need to reload it + newStateDocument.querySelectorAll('iframe').forEach((iframe) => { + const newUrl = iframe.src; + const languageId = iframe.dataset.languageId; + /** @type {HTMLIFrameElement} */ + const currentIframe = document.querySelector(`iframe[data-language-id="${languageId}"]`); + + const currentUrl = currentIframe.contentWindow.location.href; + if (newUrl !== currentUrl) { + currentIframe.src = newUrl + (currentIframe.contentWindow.location.hash ? ('#' + currentIframe.contentWindow.location.hash) : ''); + } + }); + } + + const replaceable = newStateDocument.querySelectorAll('.module-docheader, .js-replaceable'); + if (!replaceable.length) { console.error('No doc header found. in: ', html); return; } - const currentDocHeaderBars = document.querySelectorAll('.module-docheader'); - newDocHeaders.forEach((newBar, index) => { - const currentBar = currentDocHeaderBars[index]; - if (currentBar) { - currentBar.replaceWith(newBar); + const currentReplaceable = document.querySelectorAll('.module-docheader, .js-replaceable'); + replaceable.forEach((newReplaceableElement, index) => { + const currentReplaceableElement = currentReplaceable[index]; + if (currentReplaceableElement) { + currentReplaceableElement.replaceWith(newReplaceableElement); } }); diff --git a/Resources/Public/JavaScript/Backend/page-changed.test.js b/Resources/Public/JavaScript/Backend/page-changed.test.js new file mode 100644 index 0000000..0b91796 --- /dev/null +++ b/Resources/Public/JavaScript/Backend/page-changed.test.js @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {registerHooks} from 'node:module'; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === '@typo3/visual-editor/Backend/reload-all-child-frames') { + return { + shortCircuit: true, + url: `data:text/javascript,${encodeURIComponent('export function reloadAllChildFrames() {}')}`, + }; + } + return nextResolve(specifier, context); + }, +}); + +const {pageChanged, updateUrlOfWindow} = await import('./page-changed.js'); + +function createWindow(url) { + const pushedStates = []; + + return { + location: { + href: url, + }, + history: { + pushState(state, title, url) { + pushedStates.push({state, title, url: url.toString()}); + }, + }, + pushedStates, + }; +} + +function installDocument(languageIds) { + const refreshAction = { + parentNode: { + href: '', + }, + }; + + globalThis.document = { + querySelector(selector) { + if (selector === '[data-identifier="actions-refresh"]') { + return refreshAction; + } + return null; + }, + querySelectorAll(selector) { + if (['.js-visual-editor-language', 'iframe'].includes(selector)) { + return languageIds.map(languageId => ({ + dataset: { + languageId: languageId.toString(), + }, + })); + } + return []; + }, + }; + + return refreshAction; +} + +function installModuleStateStorage() { + const updates = []; + + globalThis.ModuleStateStorage = { + update(moduleName, pageId) { + updates.push({moduleName, pageId}); + }, + }; + + return updates; +} + +test('replaces a single existing language with the passed language id', () => { + installDocument([1]); + const windowObject = createWindow('https://example.test/typo3/module/web/edit?id=12&languages[0]=1'); + + const newUrl = updateUrlOfWindow(windowObject, 34, 2, {}); + + assert.equal(newUrl.searchParams.get('id'), '34'); + assert.equal(newUrl.searchParams.get('viewMode'), null); + assert.equal(newUrl.searchParams.get('languages[0]'), '2'); + assert.equal(windowObject.pushedStates[0].url, newUrl.toString()); +}); + +test('keeps existing languages and adds the passed language when the current url has multiple languages', () => { + installDocument([0, 2]); + const windowObject = createWindow('https://example.test/typo3/module/web/edit?id=12&languages[0]=0&languages[1]=2'); + + const newUrl = updateUrlOfWindow(windowObject, 34, 1, {}); + + assert.equal(newUrl.searchParams.get('id'), '34'); + assert.equal(newUrl.searchParams.get('viewMode'), '2'); + assert.equal(newUrl.searchParams.get('languages[0]'), '0'); + assert.equal(newUrl.searchParams.get('languages[1]'), '1'); + assert.equal(newUrl.searchParams.get('languages[2]'), '2'); +}); + +test('keeps existing languages unchanged when the passed language is already selected', () => { + installDocument([0, 1]); + const windowObject = createWindow('https://example.test/typo3/module/web/edit?id=12&languages[0]=0&languages[1]=1'); + + const newUrl = updateUrlOfWindow(windowObject, 34, 1, {}); + + assert.equal(newUrl.searchParams.get('id'), '34'); + assert.equal(newUrl.searchParams.get('viewMode'), '2'); + assert.equal(newUrl.searchParams.get('languages[0]'), '0'); + assert.equal(newUrl.searchParams.get('languages[1]'), '1'); + assert.equal(newUrl.searchParams.get('languages[2]'), null); +}); + +test('adds the default language when it is missing from multiple current languages', () => { + installDocument([1, 2]); + const windowObject = createWindow('https://example.test/typo3/module/web/edit?id=12&languages[0]=1&languages[1]=2'); + + const newUrl = updateUrlOfWindow(windowObject, 34, 0, {}); + + assert.equal(newUrl.searchParams.get('id'), '34'); + assert.equal(newUrl.searchParams.get('viewMode'), '2'); + assert.equal(newUrl.searchParams.get('languages[0]'), '0'); + assert.equal(newUrl.searchParams.get('languages[1]'), '1'); + assert.equal(newUrl.searchParams.get('languages[2]'), '2'); +}); + +test('replaces existing params with route argument params', () => { + installDocument([0]); + const windowObject = createWindow('https://example.test/typo3/module/web/edit?id=12¶ms[old]=removed'); + + const newUrl = updateUrlOfWindow(windowObject, 34, 0, { + 'params[new]': 'kept', + 'ignored': 'ignored', + }); + + assert.equal(newUrl.searchParams.get('id'), '34'); + assert.equal(newUrl.searchParams.get('params[old]'), null); + assert.equal(newUrl.searchParams.get('params[new]'), 'kept'); + assert.equal(newUrl.searchParams.get('ignored'), null); +}); + +test('keeps unrelated query parameters', () => { + installDocument([0]); + const windowObject = createWindow('https://example.test/typo3/module/web/edit?token=abc&id=12&returnUrl=/backend'); + + const newUrl = updateUrlOfWindow(windowObject, 34, 0, {}); + + assert.equal(newUrl.searchParams.get('id'), '34'); + assert.equal(newUrl.searchParams.get('token'), 'abc'); + assert.equal(newUrl.searchParams.get('returnUrl'), '/backend'); +}); + +test('does not update the module state when url and language did not change', () => { + installDocument([1]); + const moduleStateUpdates = installModuleStateStorage(); + const refreshAction = document.querySelector('[data-identifier="actions-refresh"]'); + const url = 'https://example.test/typo3/module/web/edit?id=34&languages%5B0%5D=1'; + globalThis.window = createWindow(url); + window.top = createWindow(url); + + pageChanged(34, 1, {}, 1); + + assert.equal(refreshAction.parentNode.href, ''); + assert.deepEqual(moduleStateUpdates, []); + assert.equal(window.top.pushedStates.length, 0); +}); + +test('updates the module state when url did not change but language changed', () => { + installDocument([1]); + const moduleStateUpdates = installModuleStateStorage(); + const refreshAction = document.querySelector('[data-identifier="actions-refresh"]'); + const url = 'https://example.test/typo3/module/web/edit?id=34&languages%5B0%5D=1'; + globalThis.window = createWindow(url); + window.top = createWindow(url); + globalThis.fetch = () => new Promise(() => {}); + + pageChanged(34, 1, {}, 0); + + assert.equal(refreshAction.parentNode.href, url); + assert.deepEqual(moduleStateUpdates, [{moduleName: 'web', pageId: 34}]); + assert.equal(window.top.pushedStates[0].url, url); +}); diff --git a/Resources/Public/JavaScript/Frontend/components/sync-content-element-moved.js b/Resources/Public/JavaScript/Frontend/components/sync-content-element-moved.js new file mode 100644 index 0000000..9c98836 --- /dev/null +++ b/Resources/Public/JavaScript/Frontend/components/sync-content-element-moved.js @@ -0,0 +1,99 @@ +import {flipInsertBefore} from '@typo3/visual-editor/Frontend/flip-insert-before'; + +/** + * @param {string} value + * @return {string} + */ +function escapeCssAttributeValue(value) { + if (globalThis.CSS?.escape) { + return CSS.escape(value); + } + + return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/:/g, '\\:'); +} + +/** + * @param {string} table + * @param {string} scrollPositionId + * @return {HTMLElement|null} + */ +function findContentElement(table, scrollPositionId) { + const element = document.querySelector(`ve-content-element[scrollPositionId="${escapeCssAttributeValue(scrollPositionId)}"]`); + if (element?.table !== table) { + return null; + } + + return element; +} + +/** + * @param {HTMLElement} element + * @return {HTMLElement|null} + */ +function findParentContentElement(element) { + const parentElement = element.parentNode; + if (!parentElement) { + return null; + } + if (parentElement instanceof ShadowRoot) { + return findParentContentElement(parentElement.host); + } + if (parentElement.tagName?.toLowerCase() === 've-content-element') { + return parentElement; + } + return findParentContentElement(parentElement); +} + +/** + * @param {{table: string, colPos: number, containerScrollPositionId: string|null}} detail + * @return {HTMLElement|null} + */ +function findTargetContentArea(detail) { + const contentAreas = document.querySelectorAll('ve-content-area'); + for (const contentArea of contentAreas) { + if (contentArea.table && contentArea.table !== detail.table) { + continue; + } + if (contentArea.colPos !== detail.colPos) { + continue; + } + + const containerElement = findParentContentElement(contentArea); + const containerScrollPositionId = containerElement?.scrollPositionId ?? null; + if (containerScrollPositionId === detail.containerScrollPositionId) { + return contentArea; + } + } + + return null; +} + +/** + * @param {{languageId: number, table: string, scrollPositionId: string, mode: 'after', targetScrollPositionId: string}|{languageId: number, table: string, scrollPositionId: string, mode: 'area-start', colPos: number, containerScrollPositionId: string|null}} detail + * @return {void} + */ +export function syncContentElementMoved(detail) { + if (detail.languageId !== 0) { + return; + } + + const sourceElement = findContentElement(detail.table, detail.scrollPositionId); + if (!sourceElement) { + return; + } + + if (detail.mode === 'after') { + const targetElement = findContentElement(detail.table, detail.targetScrollPositionId); + if (!targetElement || targetElement === sourceElement) { + return; + } + flipInsertBefore(targetElement.parentNode, sourceElement, targetElement.nextSibling); + return; + } + + const targetArea = findTargetContentArea(detail); + if (!targetArea) { + return; + } + flipInsertBefore(targetArea, sourceElement, targetArea.firstChild); +} diff --git a/Resources/Public/JavaScript/Frontend/components/ve-content-element.js b/Resources/Public/JavaScript/Frontend/components/ve-content-element.js index 247672a..1d65df7 100644 --- a/Resources/Public/JavaScript/Frontend/components/ve-content-element.js +++ b/Resources/Public/JavaScript/Frontend/components/ve-content-element.js @@ -1,13 +1,14 @@ import {css, html, LitElement} from 'lit'; import {lll} from '@typo3/core/lit-helper.js'; import {dragInProgressStore} from '@typo3/visual-editor/Frontend/stores/drag-store'; -import {sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging'; +import {onMessage, sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging'; import {openModal} from '@typo3/visual-editor/Frontend/components/ve-iframe-popup'; import {dataHandlerStore} from '@typo3/visual-editor/Frontend/stores/data-handler-store'; import {calculateAllDebounced} from '@typo3/visual-editor/Frontend/auto-no-overlap'; import {getAriaRole} from '@typo3/visual-editor/Frontend/get-aria-role'; import {getAddAboveUidPid} from '@typo3/visual-editor/Frontend/components/add-above-target'; import {shouldHideContentElement} from '@typo3/visual-editor/Frontend/components/should-hide-content-element'; +import {syncContentElementMoved} from '@typo3/visual-editor/Frontend/components/sync-content-element-moved'; import {showHiddenActive} from '@typo3/visual-editor/Shared/local-stores'; /** @@ -23,6 +24,7 @@ export class VeContentElement extends LitElement { pid: {type: Number}, colPos: {type: Number}, tx_container_parent: {type: Number}, + scrollPositionId: {type: String}, isHidden: {type: Boolean}, hiddenFieldName: {type: String}, canModifyRecord: {type: Boolean}, @@ -68,6 +70,12 @@ export class VeContentElement extends LitElement { async _delete() { dataHandlerStore.addCmd(this.table, this.uid, 'delete', 1); + if (window.veInfo.languageId === 0 && this.table === 'tt_content' && this.scrollPositionId) { + sendMessage('contentElementDeleted', { + table: this.table, + scrollPositionId: this.scrollPositionId, + }, 'parent'); + } this.remove(); } @@ -84,6 +92,8 @@ export class VeContentElement extends LitElement { super(); this.onDragInProgressChange = this.#onDragInProgressChange.bind(this); this.onShowHiddenChange = this.#onShowHiddenChange.bind(this); + this.unsubscribeSyncContentElementDeleted = null; + this.unsubscribeSyncContentElementMoved = null; this.showHidden = showHiddenActive.get(); this.isFocusWithin = false; this.addEventListener('mouseenter', () => { @@ -114,6 +124,36 @@ export class VeContentElement extends LitElement { dragInProgressStore.addEventListener('change', this.onDragInProgressChange); showHiddenActive.addEventListener('change', this.onShowHiddenChange); + this.unsubscribeSyncContentElementDeleted = onMessage('syncContentElementDeleted', + /** + * @param detail {{languageId: number, table: string, scrollPositionId: string}} + */ + (detail) => { + if (detail.languageId !== 0) { + return; + } + if (detail.table !== this.table) { + return; + } + if (detail.scrollPositionId !== this.scrollPositionId) { + return; + } + // dataHandlerStore.addCmd(this.table, this.uid, 'delete', 1); TOOD decide if we want this delete action to be visible + this.remove(); + }); + this.unsubscribeSyncContentElementMoved = onMessage('syncContentElementMoved', + /** + * @param detail {{languageId: number, table: string, scrollPositionId: string}} + */ + (detail) => { + if (detail.table !== this.table) { + return; + } + if (detail.scrollPositionId !== this.scrollPositionId) { + return; + } + syncContentElementMoved(detail); + }); if (this.parentElement.tagName.toLowerCase() !== 've-content-area') { let message = 'parent of ve-content-element must be ve-content-area, found ' + this.parentElement.tagName.toLowerCase(); @@ -125,6 +165,10 @@ export class VeContentElement extends LitElement { disconnectedCallback() { dragInProgressStore.removeEventListener('change', this.onDragInProgressChange); showHiddenActive.removeEventListener('change', this.onShowHiddenChange); + this.unsubscribeSyncContentElementDeleted?.(); + this.unsubscribeSyncContentElementDeleted = null; + this.unsubscribeSyncContentElementMoved?.(); + this.unsubscribeSyncContentElementMoved = null; super.disconnectedCallback(); } @@ -215,7 +259,7 @@ export class VeContentElement extends LitElement { */ updated(changedProperties) { // hide the whole element when "Show hidden" is off and this element is hidden - this.style.display = this.hideBecauseHidden ? 'none' : ''; + this.classList.toggle('ve-hidden', this.hideBecauseHidden); } render() { @@ -308,6 +352,10 @@ export class VeContentElement extends LitElement { overflow: initial !important; } + :host(.ve-hidden) { + display: none; + } + .border { content: ''; position: absolute; @@ -378,7 +426,7 @@ export class VeContentElement extends LitElement { .action-bar.hidden { display: flex; - opacity: 0.5; + opacity: 0.7; pointer-events: initial; } diff --git a/Resources/Public/JavaScript/Frontend/components/ve-drop-zone.js b/Resources/Public/JavaScript/Frontend/components/ve-drop-zone.js index 7670601..fae6ce4 100644 --- a/Resources/Public/JavaScript/Frontend/components/ve-drop-zone.js +++ b/Resources/Public/JavaScript/Frontend/components/ve-drop-zone.js @@ -235,6 +235,7 @@ export class VeDropZone extends LitElement { } sourceElement.setAttribute('colPos', this.colPos); sourceElement.setAttribute('tx_container_parent', this.tx_container_parent); + this.sendContentElementMoved(firstParent, sourceElement); switch (firstParent.tagName.toLowerCase()) { case 've-content-element': @@ -428,6 +429,59 @@ export class VeDropZone extends LitElement { return this.isAnyOfMyParents(table, uid, parentElement); } + /** + * @param {HTMLElement} firstParent + * @param {HTMLElement} sourceElement + * @return {void} + */ + sendContentElementMoved(firstParent, sourceElement) { + if (window.veInfo.languageId !== 0 || sourceElement.table !== 'tt_content' || !sourceElement.scrollPositionId) { + return; + } + + const target = this.getContentElementMovedTarget(firstParent); + if (target.mode === 'after' && !target.targetScrollPositionId) { + return; + } + + sendMessage('contentElementMoved', { + table: sourceElement.table, + scrollPositionId: sourceElement.scrollPositionId, + ...target, + }, 'parent'); + } + + /** + * @param {HTMLElement} firstParent + * @return {{mode: 'after', targetScrollPositionId: string}|{mode: 'area-start', colPos: number, containerScrollPositionId: string|null}} + */ + getContentElementMovedTarget(firstParent) { + if (firstParent.tagName.toLowerCase() === 've-content-element') { + return { + mode: 'after', + targetScrollPositionId: firstParent.scrollPositionId, + }; + } + + return { + mode: 'area-start', + colPos: this.colPos, + containerScrollPositionId: this.getContainerScrollPositionId(), + }; + } + + /** + * @return {string|null} + */ + getContainerScrollPositionId() { + const uidOfParent = this.tx_container_parent || (this.colPos > 99 ? parseInt(this.colPos / 100) : 0); + if (!uidOfParent) { + return null; + } + + return document.querySelector('ve-content-element[id="' + this.table + ':' + uidOfParent + '"]')?.scrollPositionId ?? null; + } + /** * @param uid {number} * @return {string} diff --git a/Resources/Public/JavaScript/Frontend/components/ve-editable-rich-text.js b/Resources/Public/JavaScript/Frontend/components/ve-editable-rich-text.js index 0d08e19..c02595f 100644 --- a/Resources/Public/JavaScript/Frontend/components/ve-editable-rich-text.js +++ b/Resources/Public/JavaScript/Frontend/components/ve-editable-rich-text.js @@ -7,6 +7,7 @@ import {removeRuleBySelector} from '@typo3/visual-editor/Shared/remove-rule-by-s import {dataHandlerStore} from '@typo3/visual-editor/Frontend/stores/data-handler-store'; import {showEmptyActive} from '@typo3/visual-editor/Shared/local-stores'; import {dragInProgressStore} from '@typo3/visual-editor/Frontend/stores/drag-store'; +import {onMessage, sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging'; /** * Styles are in editable.css @@ -23,8 +24,10 @@ export class VeEditableRichText extends LitElement { table: {type: String}, uid: {type: Number}, field: {type: String}, + fieldPositionId: {type: String}, placeholder: {type: String}, options: {type: Object}, + highlighted: {type: Boolean, reflect: true}, showEmpty: {type: Boolean}, }; @@ -36,26 +39,41 @@ export class VeEditableRichText extends LitElement { constructor() { super(); + this.highlighted = false; this.showEmpty = showEmptyActive.get(); this.onDataHandlerChange = this.#onDataHandlerChange.bind(this); this.onShowEmptyChange = this.#onShowEmptyChange.bind(this); this.onDragInProgressChange = this.#onDragInProgressChange.bind(this); + this.onSyncEditableFieldFocus = this.#onSyncEditableFieldFocus.bind(this); + this.onEditableFocus = this.#onEditableFocus.bind(this); + this.onEditableBlur = this.#onEditableBlur.bind(this); + this.editableElement = null; + this.unsubscribeSyncEditableFieldFocus = null; } connectedCallback() { super.connectedCallback(); - this.value = this.innerHTML; + if (this.value === undefined) { + // only read innerHTML once! + this.value = this.innerHTML; + } this.empty = this.value === ''; dataHandlerStore.addEventListener('change', this.onDataHandlerChange); showEmptyActive.addEventListener('change', this.onShowEmptyChange); dragInProgressStore.addEventListener('change', this.onDragInProgressChange); + this.unsubscribeSyncEditableFieldFocus = onMessage('syncEditableFieldFocus', this.onSyncEditableFieldFocus); } disconnectedCallback() { dataHandlerStore.removeEventListener('change', this.onDataHandlerChange); showEmptyActive.removeEventListener('change', this.onShowEmptyChange); dragInProgressStore.removeEventListener('change', this.onDragInProgressChange); + this.unsubscribeSyncEditableFieldFocus?.(); + this.unsubscribeSyncEditableFieldFocus = null; + this.editableElement?.removeEventListener('focus', this.onEditableFocus); + this.editableElement?.removeEventListener('blur', this.onEditableBlur); + this.editableElement = null; super.disconnectedCallback(); } @@ -73,8 +91,11 @@ export class VeEditableRichText extends LitElement { this.editor = await initCKEditorInstance(this.options || {}, wrapper, wrapper, Editor); const editableElement = this.editor.ui.getEditableElement(); if (editableElement instanceof HTMLElement) { + this.editableElement = editableElement; const fieldLabel = this.name || this.title || this.field || this.placeholder; editableElement.setAttribute('aria-label', lll('editable.title', fieldLabel)); + editableElement.addEventListener('focus', this.onEditableFocus); + editableElement.addEventListener('blur', this.onEditableBlur); } this.editor.editing.view.document.getRoot('main').placeholder = this.placeholder; this.editor.model.document.on('change:data', () => { @@ -129,6 +150,31 @@ export class VeEditableRichText extends LitElement { this.style.pointerEvents = dragInProgressStore.value ? 'none' : ''; } + #onEditableFocus() { + sendMessage('editableFieldFocusChanged', { + fieldPositionId: this.fieldPositionId, + focused: true, + }, 'parent'); + } + + #onEditableBlur() { + sendMessage('editableFieldFocusChanged', { + fieldPositionId: this.fieldPositionId, + focused: false, + }, 'parent'); + } + + /** + * @param {{languageId: number|string, fieldPositionId: string, focused: boolean}} detail + */ + #onSyncEditableFieldFocus(detail) { + if (String(detail.languageId) === String(window.veInfo.languageId)) { + return; + } + + this.highlighted = detail.focused && detail.fieldPositionId === this.fieldPositionId; + } + #isRelevantDataHandlerEvent(detail) { if (!detail || detail.scope === 'global') { return true; diff --git a/Resources/Public/JavaScript/Frontend/components/ve-editable-text.js b/Resources/Public/JavaScript/Frontend/components/ve-editable-text.js index 184fff8..d0bf32d 100644 --- a/Resources/Public/JavaScript/Frontend/components/ve-editable-text.js +++ b/Resources/Public/JavaScript/Frontend/components/ve-editable-text.js @@ -8,6 +8,7 @@ import '@typo3/visual-editor/Frontend/components/ve-validation-overlay'; import {getEditValue, insertTextAtSelection} from '@typo3/visual-editor/Frontend/components/ve-editable-text/editing'; import {getValidationIssues, normalizeValue} from '@typo3/visual-editor/Frontend/components/ve-editable-text/validation'; import {getCaretOffset, setCaretPosition} from '@typo3/visual-editor/Frontend/caret-helper'; +import {onMessage, sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging'; /** * @extends {HTMLElement} @@ -21,6 +22,7 @@ export class VeEditableText extends LitElement { table: {type: String}, uid: {type: Number}, field: {type: String}, + fieldPositionId: {type: String}, valueInitial: {type: String}, placeholder: {type: String}, allowNewlines: {type: Boolean}, @@ -29,6 +31,7 @@ export class VeEditableText extends LitElement { validationErrors: {type: Array}, showEmpty: {type: Boolean}, focused: {type: Boolean}, + highlighted: {type: Boolean, reflect: true}, hovered: {type: Boolean}, }; @@ -39,6 +42,7 @@ export class VeEditableText extends LitElement { this.validationErrors = []; this.showEmpty = showEmptyActive.get(); this.focused = false; + this.highlighted = false; this.hovered = false; this.shakeTimeout = null; this.deniedInputPulseTimeout = null; @@ -54,6 +58,8 @@ export class VeEditableText extends LitElement { this.onContextmenu = this.#onContextmenu.bind(this); this.onShowEmptyChange = this.#onShowEmptyChange.bind(this); this.onDataHandlerChange = this.#onDataHandlerChange.bind(this); + this.onSyncEditableFieldFocus = this.#onSyncEditableFieldFocus.bind(this); + this.unsubscribeSyncEditableFieldFocus = null; } connectedCallback() { @@ -73,6 +79,7 @@ export class VeEditableText extends LitElement { this.addEventListener('contextmenu', this.onContextmenu); showEmptyActive.addEventListener('change', this.onShowEmptyChange); dataHandlerStore.addEventListener('change', this.onDataHandlerChange); + this.unsubscribeSyncEditableFieldFocus = onMessage('syncEditableFieldFocus', this.onSyncEditableFieldFocus); } disconnectedCallback() { @@ -87,6 +94,8 @@ export class VeEditableText extends LitElement { this.removeEventListener('contextmenu', this.onContextmenu); showEmptyActive.removeEventListener('change', this.onShowEmptyChange); dataHandlerStore.removeEventListener('change', this.onDataHandlerChange); + this.unsubscribeSyncEditableFieldFocus?.(); + this.unsubscribeSyncEditableFieldFocus = null; if (this.shakeTimeout) { clearTimeout(this.shakeTimeout); this.shakeTimeout = null; @@ -202,6 +211,7 @@ export class VeEditableText extends LitElement { changed: this.changed, empty: isEmpty, invalid: this.invalid, + highlighted: this.highlighted, block: !shouldBeInline, })} style="--button-count: ${buttonCount};" @@ -461,6 +471,10 @@ export class VeEditableText extends LitElement { #handleFocus() { this.focused = true; + sendMessage('editableFieldFocusChanged', { + fieldPositionId: this.fieldPositionId, + focused: true, + }, 'parent'); // in chromium, we need to wait until we can the caret position requestAnimationFrame(() => { @@ -476,9 +490,24 @@ export class VeEditableText extends LitElement { #handleBlur() { this.focused = false; + sendMessage('editableFieldFocusChanged', { + fieldPositionId: this.fieldPositionId, + focused: false, + }, 'parent'); this.#setSlotText(this.#validateAndStore(this.#editableTextToStoredText(this.#getSlotText()))); } + /** + * @param {{languageId: number|string, fieldPositionId: string, focused: boolean}} detail + */ + #onSyncEditableFieldFocus(detail) { + if (String(detail.languageId) === String(window.veInfo.languageId)) { + return; + } + + this.highlighted = detail.focused && detail.fieldPositionId === this.fieldPositionId; + } + /** * @param {string} reason * @param {HTMLElement} element @@ -615,12 +644,9 @@ export class VeEditableText extends LitElement { } } - .slot:hover, .slot:focus { - box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.50) inset; - backdrop-filter: blur(10px) invert(20%); - outline-color: #5432fe; - } - + .slot:hover, + .slot:focus, + .slot.highlighted, .slot.empty { box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.50) inset; backdrop-filter: blur(10px) invert(20%); diff --git a/Resources/Public/JavaScript/Frontend/index.js b/Resources/Public/JavaScript/Frontend/index.js index 7b29adf..16312dd 100644 --- a/Resources/Public/JavaScript/Frontend/index.js +++ b/Resources/Public/JavaScript/Frontend/index.js @@ -9,11 +9,12 @@ import '@typo3/visual-editor/Frontend/components/ve-icon'; import '@typo3/visual-editor/Frontend/components/ve-error'; import '@typo3/visual-editor/Frontend/components/ve-iframe-popup'; import {sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging'; -import {initSaveScrollPosition} from '@typo3/visual-editor/Frontend/init-save-scroll-position'; import {initializeNavigationInterception} from '@typo3/visual-editor/Frontend/initialize-navigation-interception'; import {initializeSaveHandling} from '@typo3/visual-editor/Frontend/initialize-save-handling'; import {initializeSpotlightHandling} from '@typo3/visual-editor/Frontend/initialize-spotlight-handling'; import {initializeImageHandling} from '@typo3/visual-editor/Frontend/initialize-image-handling'; +import {initializeScrollPositionSyncAndSave} from '@typo3/visual-editor/Frontend/scroll-position-sync'; +import {initializeIframeLoadingSignal} from '@typo3/visual-editor/Frontend/initialize-iframe-loading-signal'; if (window.location.hash === '#ve-close') { sendMessage('closeModal'); @@ -26,7 +27,8 @@ if (window.veInfo) { } initializeSpotlightHandling(); +initializeIframeLoadingSignal(); initializeSaveHandling(); -initSaveScrollPosition(); initializeNavigationInterception(); initializeImageHandling(); +initializeScrollPositionSyncAndSave(); diff --git a/Resources/Public/JavaScript/Frontend/init-save-scroll-position.js b/Resources/Public/JavaScript/Frontend/init-save-scroll-position.js index ed24725..e69de29 100644 --- a/Resources/Public/JavaScript/Frontend/init-save-scroll-position.js +++ b/Resources/Public/JavaScript/Frontend/init-save-scroll-position.js @@ -1,115 +0,0 @@ -import {sortTheNodesThroughTheShadowRootsAndSlots} from '@typo3/visual-editor/Frontend/sort-the-nodes-through-the-shadow-roots-and-slots'; - -/** - * @return {void} - */ -export function initSaveScrollPosition() { - if (!getPositions()) { - // no position to restore - - document.addEventListener('scrollend', saveScrollPosition); - window.addEventListener('resize', saveScrollPosition); - return; - } - if ('scrollRestoration' in history) { - history.scrollRestoration = 'manual'; - } - - document.addEventListener('readystatechange', () => { - setTimeout(() => { - scrollToPosition(); - - setTimeout(() => { - // init save listeners: - document.addEventListener('scrollend', saveScrollPosition); - window.addEventListener('resize', saveScrollPosition); - }, 100); - }, 50); - }); -} - -/** - * @param positions {Array<{id: string, innerOffsetY: number}>} - * @return {void} - */ -function setPositions(positions) { - const item = { - positions, - url: window.location.href, - time: Date.now().toFixed(), - }; - - sessionStorage.setItem('t3-ve-scroll-position', JSON.stringify(item)); -} - -/** - * @return {Array<{id: string, innerOffsetY: number}>} - */ -function getPositions() { - const item = JSON.parse(sessionStorage.getItem('t3-ve-scroll-position') || '{}'); - if (!item || !item.url || item.url !== window.location.href) { - return null; - } - // only if it was saved in the last 1h: - if (Date.now() - parseInt(item.time, 10) > 3600 * 1000) { - return null; - } - return Array.isArray(item.positions) ? item.positions : null; -} - -/** - * @return {void} - */ -function scrollToPosition() { - const positions = getPositions(); - if (!positions) { - return; - } - - let element = null; - let position = null; - for (const pos of positions) { - element = document.getElementById(pos.id); - if (element) { - position = pos; - break; - } - } - if (!element || !position) { - return; - } - - const elementRect = element.getBoundingClientRect(); - const scrollToY = elementRect.top + position.innerOffsetY; - window.scrollTo({ - top: scrollToY, - behavior: 'instant', - }); -} - -/** - * @return {void} - */ -function saveScrollPosition() { - const possibleSaveTargets = document.querySelectorAll('ve-content-element[id]:not([id=""])'); - // we want to find the nearest element to the top of the viewport that is still above or at the top: - - // We need to order the elements from bottom to top - const list = sortTheNodesThroughTheShadowRootsAndSlots([...possibleSaveTargets]); - - const positions = []; - // so we iterate in reverse order: - for (const element of list.toReversed()) { - const elementRect = element.getBoundingClientRect(); - if (elementRect.top < 0) { - const innerOffsetY = Math.min(window.innerHeight, -elementRect.top); - positions.push({ - id: element.id, - innerOffsetY: innerOffsetY, - }); - } - } - // take only first 10 positions: - positions.splice(10); - setPositions(positions); -} diff --git a/Resources/Public/JavaScript/Frontend/initialize-iframe-loading-signal.js b/Resources/Public/JavaScript/Frontend/initialize-iframe-loading-signal.js new file mode 100644 index 0000000..a4071b3 --- /dev/null +++ b/Resources/Public/JavaScript/Frontend/initialize-iframe-loading-signal.js @@ -0,0 +1,15 @@ +import {sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging'; + +export function initializeIframeLoadingSignal() { + let hasSentLoadingStarted = false; + const sendLoadingStarted = () => { + if (hasSentLoadingStarted) { + return; + } + hasSentLoadingStarted = true; + sendMessage('iframeLoadingStarted', null, 'parent'); + }; + + window.addEventListener('pagehide', sendLoadingStarted, {once: true}); + window.addEventListener('beforeunload', sendLoadingStarted, {once: true}); +} diff --git a/Resources/Public/JavaScript/Frontend/initialize-save-handling.js b/Resources/Public/JavaScript/Frontend/initialize-save-handling.js index 3f62510..be519fb 100644 --- a/Resources/Public/JavaScript/Frontend/initialize-save-handling.js +++ b/Resources/Public/JavaScript/Frontend/initialize-save-handling.js @@ -32,8 +32,10 @@ export function initializeSaveHandling() { new InterceptUserActionsGuard(dataHandlerStore); } -onMessage('focusFirstInvalidField', () => { - focusFirstInvalidField(); +onMessage('focusFirstInvalidField', ({languageId}) => { + if (languageId === window.veInfo.langaugeId) { + focusFirstInvalidField(); + } }); onMessage('saveEnded', () => { diff --git a/Resources/Public/JavaScript/Frontend/scroll-position-sync-helpers.js b/Resources/Public/JavaScript/Frontend/scroll-position-sync-helpers.js new file mode 100644 index 0000000..aba6857 --- /dev/null +++ b/Resources/Public/JavaScript/Frontend/scroll-position-sync-helpers.js @@ -0,0 +1,163 @@ +/** + * @param {number} value + * @param {number} min + * @param {number} max + * @return {number} + */ +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +/** + * @param {string} value + * @return {string} + */ +function escapeCssAttributeValue(value) { + if (globalThis.CSS?.escape) { + return CSS.escape(value); + } + + return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/:/g, '\\:'); +} + +/** + * @param {{scrollY: number, innerHeight: number, document: {scrollingElement: {scrollHeight: number}}}} windowObject + * @return {number} + */ +export function getScrollProgress(windowObject) { + const maxScrollTop = windowObject.document.scrollingElement.scrollHeight - windowObject.innerHeight; + if (maxScrollTop <= 0) { + return 0; + } + + return clamp(windowObject.scrollY / maxScrollTop, 0, 1); +} + +/** + * @typedef {Object} ScrollPositionState + * @property {Array<{scrollPositionId: string, innerOffsetY: number}>} positions + * @property {number} scrollProgress + */ + +/** + * @param {Array<{getAttribute: (name: string) => string|null, getBoundingClientRect: () => {top: number, width: number, height: number}}>} elements + * @param {number} limit + * @return {Array<{scrollPositionId: string, innerOffsetY: number}>} + */ +export function collectNearestScrollPositions(elements, limit = 5) { + return elements + .filter(element => element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0) + .map(element => ({ + scrollPositionId: element.getAttribute('scrollPositionId'), + innerOffsetY: -element.getBoundingClientRect().top, + })) + .filter(({scrollPositionId}) => scrollPositionId) + .sort((a, b) => Math.abs(a.innerOffsetY) - Math.abs(b.innerOffsetY)) + .slice(0, limit); +} + +/** + * @param {{querySelector: (selector: string) => {getBoundingClientRect: () => {top: number, width: number, height: number}}|null, scrollingElement: {scrollHeight: number}}} documentObject + * @param {{scrollY: number, innerHeight: number}} windowObject + * @param {ScrollPositionState} scrollPosition + * @return {number|null} + */ +export function getVisibleScrollTargetY(documentObject, windowObject, scrollPosition) { + for (const position of scrollPosition.positions) { + const element = documentObject.querySelector(`ve-content-element[scrollPositionId="${escapeCssAttributeValue(position.scrollPositionId)}"]`); + if (element) { + // skip if element is hidden: + if (element.getBoundingClientRect().width === 0 || element.getBoundingClientRect().height === 0) { + continue; + } + return windowObject.scrollY + element.getBoundingClientRect().top + position.innerOffsetY; + } + } + + return null; +} + +/** + * @param {{querySelector: (selector: string) => {getBoundingClientRect: () => {top: number, width: number, height: number}}|null, scrollingElement: {scrollHeight: number}}} documentObject + * @param {{scrollY: number, innerHeight: number}} windowObject + * @param {ScrollPositionState} scrollPosition + * @return {number} + */ +export function getScrollTargetY(documentObject, windowObject, scrollPosition) { + const visibleScrollTargetY = getVisibleScrollTargetY(documentObject, windowObject, scrollPosition); + if (visibleScrollTargetY !== null) { + return visibleScrollTargetY; + } + + const maxScrollTop = documentObject.scrollingElement.scrollHeight - windowObject.innerHeight; + return clamp(scrollPosition.scrollProgress, 0, 1) * Math.max(maxScrollTop, 0); +} + +/** + * @param {unknown} value + * @return {value is ScrollPositionState} + */ +function isScrollPositionState(value) { + if (!value || typeof value !== 'object' || !Array.isArray(value.positions) || typeof value.scrollProgress !== 'number') { + return false; + } + + return value.positions.every(position => ( + position + && typeof position === 'object' + && typeof position.scrollPositionId === 'string' + && typeof position.innerOffsetY === 'number' + )); +} + +/** + * @param {ScrollPositionState} scrollPosition + * @param {string} page + * @param {number} time + * @return {{positions: Array<{scrollPositionId: string, innerOffsetY: number}>, scrollProgress: number, page: string, time: string}} + */ +export function createStoredScrollPosition(scrollPosition, page, time = Date.now()) { + return { + positions: scrollPosition.positions, + scrollProgress: scrollPosition.scrollProgress, + page, + time: time.toFixed(), + }; +} + +/** + * @param {string|null} storedValue + * @param {string} page + * @param {number} now + * @return {ScrollPositionState|null} + */ +export function parseStoredScrollPosition(storedValue, page, now = Date.now()) { + if (!storedValue) { + return null; + } + + let item; + try { + item = JSON.parse(storedValue); + } catch { + return null; + } + + if (!item || typeof item !== 'object' || item.page !== page || typeof item.time !== 'string') { + return null; + } + + const storedTime = parseInt(item.time, 10); + if (Number.isNaN(storedTime) || now - storedTime > 3600 * 1000) { + return null; + } + + if (!isScrollPositionState(item)) { + return null; + } + + return { + positions: item.positions, + scrollProgress: item.scrollProgress, + }; +} diff --git a/Resources/Public/JavaScript/Frontend/scroll-position-sync.js b/Resources/Public/JavaScript/Frontend/scroll-position-sync.js new file mode 100644 index 0000000..f08036c --- /dev/null +++ b/Resources/Public/JavaScript/Frontend/scroll-position-sync.js @@ -0,0 +1,130 @@ +import {onMessage, sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging'; +import { + collectNearestScrollPositions, + createStoredScrollPosition, + getScrollProgress, + getScrollTargetY, + getVisibleScrollTargetY, + parseStoredScrollPosition, +} from '@typo3/visual-editor/Frontend/scroll-position-sync-helpers'; + +const scrollPositionSelector = 've-content-element[scrollPositionId]'; +const scrollPositionStorageKey = 't3-ve-scroll-position'; +let ignoreNextScrollEventsUntil = 0; +let queuedScrollMessage = false; + +/** + * @return {string} + */ +function getScrollPositionStoragePage() { + return String(window.veInfo?.pageId ?? window.location.href); +} + +/** + * @return {{positions: Array<{scrollPositionId: string, innerOffsetY: number}>, scrollProgress: number}} + */ +function getCurrentScrollPosition() { + const elements = [...document.querySelectorAll(scrollPositionSelector)]; + return { + positions: collectNearestScrollPositions(elements), + scrollProgress: getScrollProgress(window), + }; +} + +/** + * @param {{positions: Array<{scrollPositionId: string, innerOffsetY: number}>, scrollProgress: number}} scrollPosition + * @return {void} + */ +function saveScrollPosition(scrollPosition) { + sessionStorage.setItem( + scrollPositionStorageKey, + JSON.stringify(createStoredScrollPosition(scrollPosition, getScrollPositionStoragePage())), + ); +} + +/** + * @return {{positions: Array<{scrollPositionId: string, innerOffsetY: number}>, scrollProgress: number}|null} + */ +function getSavedScrollPosition() { + return parseStoredScrollPosition( + sessionStorage.getItem(scrollPositionStorageKey), + getScrollPositionStoragePage(), + ); +} + +/** + * @return {void} + */ +function syncAndSaveCurrentScrollPosition() { + const scrollPosition = getCurrentScrollPosition(); + saveScrollPosition(scrollPosition); + if (scrollPosition.positions.length === 0) { + return; + } + sendMessage('scrollPositionChanged', scrollPosition, 'parent'); +} + +/** + * @return {void} + */ +function queueCurrentScrollPosition() { + if (Date.now() < ignoreNextScrollEventsUntil || queuedScrollMessage) { + return; + } + + queuedScrollMessage = true; + requestAnimationFrame(() => { + queuedScrollMessage = false; + if (Date.now() >= ignoreNextScrollEventsUntil) { + syncAndSaveCurrentScrollPosition(); + } + }); +} + +/** + * @return {void} + */ +function restoreSavedScrollPosition() { + const scrollPosition = getSavedScrollPosition(); + if (!scrollPosition) { + return; + } + + window.scrollTo({ + top: getScrollTargetY(document, window, scrollPosition), + behavior: 'instant', + }); +} + +/** + * @return {void} + */ +export function initializeScrollPositionSyncAndSave() { + if (getSavedScrollPosition() && 'scrollRestoration' in history) { + history.scrollRestoration = 'manual'; + } + + document.addEventListener('readystatechange', () => { + setTimeout(() => { + restoreSavedScrollPosition(); + }, 50); + }); + + window.addEventListener('scroll', queueCurrentScrollPosition, {passive: true}); + onMessage('syncScrollPosition', (scrollPosition) => { + if (String(scrollPosition.languageId) === String(window.veInfo.languageId)) { + return; + } + + const scrollTargetY = getVisibleScrollTargetY(document, window, scrollPosition); + if (scrollTargetY === null) { + return; + } + + ignoreNextScrollEventsUntil = Date.now() + 250; + window.scrollTo({ + top: scrollTargetY, + behavior: 'instant', + }); + }); +} diff --git a/Resources/Public/JavaScript/Frontend/scroll-position-sync.test.js b/Resources/Public/JavaScript/Frontend/scroll-position-sync.test.js new file mode 100644 index 0000000..523748e --- /dev/null +++ b/Resources/Public/JavaScript/Frontend/scroll-position-sync.test.js @@ -0,0 +1,236 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + collectNearestScrollPositions, + createStoredScrollPosition, + getScrollProgress, + getScrollTargetY, + getVisibleScrollTargetY, + parseStoredScrollPosition, +} from './scroll-position-sync-helpers.js'; + +function createElement(scrollPositionId, top, width = 100, height = 50) { + return { + getAttribute(name) { + return name === 'scrollPositionId' ? scrollPositionId : null; + }, + getBoundingClientRect() { + return {top, width, height}; + }, + }; +} + +test('collectNearestScrollPositions returns the five nearest scroll positions', () => { + const positions = collectNearestScrollPositions([ + createElement('tt_content:1', -400), + createElement('tt_content:2', -20), + createElement('tt_content:3', 120), + createElement('tt_content:4', -60), + createElement('tt_content:5', 300), + createElement('tt_content:6', -10), + createElement('tt_content:7', 500), + ]); + + assert.deepEqual(positions, [ + {scrollPositionId: 'tt_content:6', innerOffsetY: 10}, + {scrollPositionId: 'tt_content:2', innerOffsetY: 20}, + {scrollPositionId: 'tt_content:4', innerOffsetY: 60}, + {scrollPositionId: 'tt_content:3', innerOffsetY: -120}, + {scrollPositionId: 'tt_content:5', innerOffsetY: -300}, + ]); +}); + +test('getScrollProgress returns relative scroll progress', () => { + assert.equal(getScrollProgress({ + scrollY: 250, + innerHeight: 500, + document: {scrollingElement: {scrollHeight: 1500}}, + }), 0.25); +}); + +test('getScrollProgress clamps when the page cannot scroll', () => { + assert.equal(getScrollProgress({ + scrollY: 250, + innerHeight: 500, + document: {scrollingElement: {scrollHeight: 400}}, + }), 0); +}); + +test('getScrollTargetY uses the first matching scroll position', () => { + const documentObject = { + scrollingElement: {scrollHeight: 3000}, + querySelector(selector) { + if (selector === 've-content-element[scrollPositionId="tt_content\\:2"]') { + return createElement('tt_content:2', 100); + } + return null; + }, + }; + + assert.equal(getScrollTargetY(documentObject, {scrollY: 500, innerHeight: 800}, { + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 50}, + {scrollPositionId: 'tt_content:2', innerOffsetY: 75}, + ], + scrollProgress: 0.4, + }), 675); +}); + +test('getScrollTargetY falls back to scroll progress when no position matches', () => { + const documentObject = { + scrollingElement: {scrollHeight: 3000}, + querySelector() { + return null; + }, + }; + + assert.equal(getScrollTargetY(documentObject, {scrollY: 500, innerHeight: 1000}, { + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 50}, + {scrollPositionId: 'tt_content:2', innerOffsetY: 75}, + ], + scrollProgress: 0.25, + }), 500); +}); + +test('getVisibleScrollTargetY uses the first visible matching scroll position', () => { + const documentObject = { + scrollingElement: {scrollHeight: 3000}, + querySelector(selector) { + if (selector === 've-content-element[scrollPositionId="tt_content\\:2"]') { + return createElement('tt_content:2', 100); + } + return null; + }, + }; + + assert.equal(getVisibleScrollTargetY(documentObject, {scrollY: 500, innerHeight: 800}, { + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 50}, + {scrollPositionId: 'tt_content:2', innerOffsetY: 75}, + ], + scrollProgress: 0.4, + }), 675); +}); + +test('getVisibleScrollTargetY skips hidden matching scroll positions', () => { + const documentObject = { + scrollingElement: {scrollHeight: 3000}, + querySelector(selector) { + if (selector === 've-content-element[scrollPositionId="tt_content\\:1"]') { + return createElement('tt_content:1', 100, 0, 50); + } + if (selector === 've-content-element[scrollPositionId="tt_content\\:2"]') { + return createElement('tt_content:2', 200); + } + return null; + }, + }; + + assert.equal(getVisibleScrollTargetY(documentObject, {scrollY: 500, innerHeight: 800}, { + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 50}, + {scrollPositionId: 'tt_content:2', innerOffsetY: 75}, + ], + scrollProgress: 0.4, + }), 775); +}); + +test('getVisibleScrollTargetY returns null when no visible position matches', () => { + const documentObject = { + scrollingElement: {scrollHeight: 3000}, + querySelector(selector) { + if (selector === 've-content-element[scrollPositionId="tt_content\\:1"]') { + return createElement('tt_content:1', 100, 100, 0); + } + return null; + }, + }; + + assert.equal(getVisibleScrollTargetY(documentObject, {scrollY: 500, innerHeight: 1000}, { + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 50}, + {scrollPositionId: 'tt_content:2', innerOffsetY: 75}, + ], + scrollProgress: 0.25, + }), null); +}); + +test('createStoredScrollPosition creates a persistable payload', () => { + assert.deepEqual(createStoredScrollPosition({ + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 25}, + ], + scrollProgress: 0.2, + }, 'https://example.org/current', 123456), { + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 25}, + ], + scrollProgress: 0.2, + page: 'https://example.org/current', + time: '123456', + }); +}); + +test('parseStoredScrollPosition returns valid current scroll positions', () => { + const storedValue = JSON.stringify(createStoredScrollPosition({ + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 25}, + ], + scrollProgress: 0.2, + }, 'https://example.org/current', 1000)); + + assert.deepEqual(parseStoredScrollPosition(storedValue, 'https://example.org/current', 2000), { + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 25}, + ], + scrollProgress: 0.2, + }); +}); + +test('parseStoredScrollPosition rejects page mismatches', () => { + const storedValue = JSON.stringify(createStoredScrollPosition({ + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 25}, + ], + scrollProgress: 0.2, + }, 'https://example.org/current', 1000)); + + assert.equal(parseStoredScrollPosition(storedValue, 'https://example.org/other', 2000), null); +}); + +test('parseStoredScrollPosition rejects expired positions', () => { + const storedValue = JSON.stringify(createStoredScrollPosition({ + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 25}, + ], + scrollProgress: 0.2, + }, 'https://example.org/current', 1000)); + + assert.equal(parseStoredScrollPosition(storedValue, 'https://example.org/current', 3601001), null); +}); + +test('parseStoredScrollPosition rejects old id based positions', () => { + const storedValue = JSON.stringify({ + positions: [ + {id: 'tt_content:1', innerOffsetY: 25}, + ], + page: 'https://example.org/current', + time: '1000', + }); + + assert.equal(parseStoredScrollPosition(storedValue, 'https://example.org/current', 2000), null); +}); + +test('parseStoredScrollPosition rejects invalid timestamps', () => { + const storedValue = JSON.stringify({ + positions: [ + {scrollPositionId: 'tt_content:1', innerOffsetY: 25}, + ], + scrollProgress: 0.2, + page: 'https://example.org/current', + time: 'invalid', + }); + + assert.equal(parseStoredScrollPosition(storedValue, 'https://example.org/current', 2000), null); +}); diff --git a/Resources/Public/JavaScript/Shared/iframe-messaging.js b/Resources/Public/JavaScript/Shared/iframe-messaging.js index 0e47d92..99c2338 100644 --- a/Resources/Public/JavaScript/Shared/iframe-messaging.js +++ b/Resources/Public/JavaScript/Shared/iframe-messaging.js @@ -10,6 +10,16 @@ * @property openInMiddleFrame {String} * @property localStoreChange {{key: String, value: any}} * @property localStoreRequest {String} + * @property focusFirstInvalidField {languageId: number} + * @property scrollPositionChanged {{positions: Array<{scrollPositionId: string, innerOffsetY: number}>, scrollProgress: number}} + * @property syncScrollPosition {{languageId: number, positions: Array<{scrollPositionId: string, innerOffsetY: number}>, scrollProgress: number}} + * @property editableFieldFocusChanged {{fieldPositionId: string, focused: boolean}} + * @property syncEditableFieldFocus {{languageId: number, fieldPositionId: string, focused: boolean}} + * @property contentElementDeleted {{table: string, scrollPositionId: string}} + * @property syncContentElementDeleted {{languageId: number, table: string, scrollPositionId: string}} + * @property contentElementMoved {{table: string, scrollPositionId: string, mode: 'after', targetScrollPositionId: string}|{table: string, scrollPositionId: string, mode: 'area-start', colPos: number, containerScrollPositionId: string|null}} + * @property syncContentElementMoved {{languageId: number, table: string, scrollPositionId: string, mode: 'after', targetScrollPositionId: string}|{languageId: number, table: string, scrollPositionId: string, mode: 'area-start', colPos: number, containerScrollPositionId: string|null}} + * @property iframeLoadingStarted {null} */ /** @@ -19,7 +29,7 @@ * @returns {string} */ function getPeerOrigin() { - const editorIframe = document.querySelector('iframe#visual-editor-iframe'); + const editorIframe = document.querySelector('iframe.visual-editor-iframe'); // TODO handle this if (editorIframe) { return new URL(editorIframe.src, window.location.href).origin; } @@ -44,14 +54,17 @@ export function sendMessage(command, detail = null, sendTo = 'any') { const message = { detail, command: `ve_${command}`, + fromLanguageId: window.veInfo.languageId, }; const peerOrigin = getPeerOrigin(); - const editorIframe = document.querySelector('iframe#visual-editor-iframe'); - if (editorIframe) { + const editorIframes = document.querySelectorAll('iframe.visual-editor-iframe'); + if (editorIframes.length) { if (sendTo === 'parent') { return; } - editorIframe.contentWindow.postMessage(message, peerOrigin); + for (const editorIframe of editorIframes) { + editorIframe.contentWindow.postMessage(message, peerOrigin); + } } else { if (sendTo === 'iframe') { return; @@ -69,7 +82,7 @@ let isMessageListenerInitialized = false; /** * @template {keyof VECommandDetailMap} K * @param command {K} - * @param callback {(detail: VECommandDetailMap[K]) => void} + * @param callback {(detail: VECommandDetailMap[K], fromLanguageId: number) => void} */ export function onMessage(command, callback) { const listenerKey = `ve_${command}`; @@ -83,14 +96,16 @@ export function onMessage(command, callback) { if (event.origin !== getPeerOrigin()) { return; } - const editorIframe = document.querySelector('iframe#visual-editor-iframe'); - const expectedSource = editorIframe ? editorIframe.contentWindow : window.parent; - if (event.source !== expectedSource) { - return; + const editorIframes = document.querySelectorAll('iframe.visual-editor-iframe'); + const iframeFrom = [...editorIframes].find(iframe => iframe.contentWindow === event.source); + if (!editorIframes.length) { + if (event.source !== window.parent) { + return; + } } if (messageListeners[event.data.command]) { for (const callback of messageListeners[event.data.command]) { - callback(event.data.detail); + callback(event.data.detail, parseInt(iframeFrom?.dataset.languageId ?? event.data.fromLanguageId, 10)); } } }); diff --git a/composer.json b/composer.json index 37f46cf..b9eaf5e 100644 --- a/composer.json +++ b/composer.json @@ -70,7 +70,6 @@ "@composer normalize" ], "post-update-cmd": [ - "@composer bump --dev-only", "@composer normalize" ], "post-autoload-dump": [ diff --git a/package.json b/package.json index 16a2033..543ddc0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "private": true, + "type": "module", "scripts": { "lint": "eslint", "test": "node --test $(find Resources/Public/JavaScript -name \"*.test.js\" | sort)" diff --git a/phpstan-baseline-13.neon b/phpstan-baseline-13.neon index 4b499bc..28ebb11 100644 --- a/phpstan-baseline-13.neon +++ b/phpstan-baseline-13.neon @@ -1,23 +1,41 @@ parameters: ignoreErrors: + - + message: '#^Access to constant MULTI_SELECT on an unknown class TYPO3\\CMS\\Backend\\Template\\Components\\Buttons\\LanguageSelectorMode\.$#' + identifier: class.notFound + count: 1 + path: Classes/Backend/Controller/PageEditController.php + - message: '#^Access to constant SINGLE_SELECT on an unknown class TYPO3\\CMS\\Backend\\Template\\Components\\Buttons\\LanguageSelectorMode\.$#' identifier: class.notFound count: 1 path: Classes/Backend/Controller/PageEditController.php + - + message: '#^Access to property \$languageInformation on an unknown class TYPO3\\CMS\\Backend\\Context\\PageContext\.$#' + identifier: class.notFound + count: 2 + path: Classes/Backend/Controller/PageEditController.php + - message: '#^Access to property \$pageId on an unknown class TYPO3\\CMS\\Backend\\Context\\PageContext\.$#' identifier: class.notFound - count: 1 + count: 3 path: Classes/Backend/Controller/PageEditController.php - - message: '#^Call to an undefined method TYPO3\\CMS\\Backend\\Domain\\Repository\\Localization\\LocalizationRepository\:\:getPageTranslations\(\)\.$#' - identifier: method.notFound + message: '#^Access to property \$pageTsConfig on an unknown class TYPO3\\CMS\\Backend\\Context\\PageContext\.$#' + identifier: class.notFound count: 1 path: Classes/Backend/Controller/PageEditController.php + - + message: '#^Access to property \$selectedLanguageIds on an unknown class TYPO3\\CMS\\Backend\\Context\\PageContext\.$#' + identifier: class.notFound + count: 2 + path: Classes/Backend/Controller/PageEditController.php + - message: '#^Call to an undefined method TYPO3\\CMS\\Backend\\Template\\Components\\DocHeaderComponent\:\:disableAutomaticReloadButton\(\)\.$#' identifier: method.notFound @@ -42,6 +60,12 @@ parameters: count: 1 path: Classes/Backend/Controller/PageEditController.php + - + message: '#^Call to an undefined method TYPO3\\CMS\\Backend\\View\\Drawing\\DrawingConfiguration\:\:setSelectedLanguageIds\(\)\.$#' + identifier: method.notFound + count: 1 + path: Classes/Backend/Controller/PageEditController.php + - message: '#^Call to an undefined method TYPO3\\CMS\\Core\\DataHandling\\PageDoktypeRegistry\:\:isPageViewable\(\)\.$#' identifier: method.notFound @@ -54,6 +78,24 @@ parameters: count: 1 path: Classes/Backend/Controller/PageEditController.php + - + message: '#^Call to method createMenu\(\) on an unknown class TYPO3\\CMS\\Backend\\Template\\Components\\ComponentFactory\.$#' + identifier: class.notFound + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Call to method createMenuItem\(\) on an unknown class TYPO3\\CMS\\Backend\\Template\\Components\\ComponentFactory\.$#' + identifier: class.notFound + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Call to method hasMultipleLanguagesSelected\(\) on an unknown class TYPO3\\CMS\\Backend\\Context\\PageContext\.$#' + identifier: class.notFound + count: 1 + path: Classes/Backend/Controller/PageEditController.php + - message: '#^Cannot call method getRequestUri\(\) on TYPO3\\CMS\\Core\\Http\\NormalizedParams\|null\.$#' identifier: method.nonObject @@ -63,7 +105,7 @@ parameters: - message: '#^Class TYPO3\\CMS\\Backend\\Context\\PageContext not found\.$#' identifier: class.notFound - count: 1 + count: 3 path: Classes/Backend/Controller/PageEditController.php - @@ -73,8 +115,80 @@ parameters: path: Classes/Backend/Controller/PageEditController.php - - message: '#^Comparison operation "\<\=" between int\ and 13 is always true\.$#' - identifier: smallerOrEqual.alwaysTrue + message: '#^Class TYPO3\\CMS\\Backend\\Template\\Components\\ComponentFactory not found\.$#' + identifier: class.notFound + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Class TYPO3\\CMS\\Backend\\View\\PageLayoutContext constructor invoked with 4 parameters, 5 required\.$#' + identifier: arguments.count + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Method TYPO3\\CMS\\Backend\\View\\BackendLayout\\ContentFetcher\:\:getFlatContentRecords\(\) invoked with 2 parameters, 1 required\.$#' + identifier: arguments.count + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Method TYPO3\\CMS\\Backend\\View\\BackendLayout\\ContentFetcher\:\:getTranslationData\(\) invoked with 3 parameters, 2 required\.$#' + identifier: arguments.count + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Parameter \#1 \$backendLayout of static method TYPO3\\CMS\\Backend\\View\\Drawing\\DrawingConfiguration\:\:create\(\) expects TYPO3\\CMS\\Backend\\View\\BackendLayout\\BackendLayout, TYPO3\\CMS\\Backend\\View\\BackendLayout\\BackendLayout\|null given\.$#' + identifier: argument.type + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Parameter \#1 \$contentElements of method TYPO3\\CMS\\Backend\\View\\BackendLayout\\ContentFetcher\:\:getTranslationData\(\) expects iterable, TYPO3\\CMS\\Backend\\View\\PageLayoutContext given\.$#' + identifier: argument.type + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Parameter \#1 \$languageId of method TYPO3\\CMS\\Backend\\View\\BackendLayout\\ContentFetcher\:\:getFlatContentRecords\(\) expects int, TYPO3\\CMS\\Backend\\View\\PageLayoutContext given\.$#' + identifier: argument.type + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Parameter \#1 \$pageRecord of class TYPO3\\CMS\\Backend\\View\\PageLayoutContext constructor expects array, TYPO3\\CMS\\Backend\\Context\\PageContext given\.$#' + identifier: argument.type + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Parameter \#2 \$backendLayout of class TYPO3\\CMS\\Backend\\View\\PageLayoutContext constructor expects TYPO3\\CMS\\Backend\\View\\BackendLayout\\BackendLayout, TYPO3\\CMS\\Backend\\View\\BackendLayout\\BackendLayout\|null given\.$#' + identifier: argument.type + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Parameter \#2 \$language of method TYPO3\\CMS\\Backend\\View\\BackendLayout\\ContentFetcher\:\:getTranslationData\(\) expects int, iterable given\.$#' + identifier: argument.type + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Parameter \#3 \$site of class TYPO3\\CMS\\Backend\\View\\PageLayoutContext constructor expects TYPO3\\CMS\\Core\\Site\\Entity\\SiteInterface, TYPO3\\CMS\\Backend\\View\\Drawing\\DrawingConfiguration given\.$#' + identifier: argument.type + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Parameter \#4 \$drawingConfiguration of class TYPO3\\CMS\\Backend\\View\\PageLayoutContext constructor expects TYPO3\\CMS\\Backend\\View\\Drawing\\DrawingConfiguration, Psr\\Http\\Message\\ServerRequestInterface given\.$#' + identifier: argument.type + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Parameter \$pageContext of method TYPO3\\CMS\\VisualEditor\\Backend\\Controller\\PageEditController\:\:buildViewModeSwitchParams\(\) has invalid type TYPO3\\CMS\\Backend\\Context\\PageContext\.$#' + identifier: class.notFound count: 1 path: Classes/Backend/Controller/PageEditController.php @@ -84,6 +198,18 @@ parameters: count: 1 path: Classes/Backend/Controller/PageEditController.php + - + message: '#^Property TYPO3\\CMS\\VisualEditor\\Backend\\Controller\\PageEditController\:\:\$selectedLanguages \(list\\) does not accept array\, TYPO3\\CMS\\Core\\Site\\Entity\\SiteLanguage\>\.$#' + identifier: assign.propertyType + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Unable to resolve the template type T in call to function array_values$#' + identifier: argument.templateType + count: 1 + path: Classes/Backend/Controller/PageEditController.php + - message: '#^Call to method createPublicResource\(\) on an unknown class TYPO3\\CMS\\Core\\SystemResource\\SystemResourceFactory\.$#' identifier: class.notFound diff --git a/phpstan-baseline-14.neon b/phpstan-baseline-14.neon index 52bbf92..1bba406 100644 --- a/phpstan-baseline-14.neon +++ b/phpstan-baseline-14.neon @@ -18,6 +18,18 @@ parameters: count: 1 path: Classes/Backend/Controller/PageEditController.php + - + message: '#^Property TYPO3\\CMS\\VisualEditor\\Backend\\Controller\\PageEditController\:\:\$policyRegistry is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: Classes/Backend/Controller/PageEditController.php + + - + message: '#^Property TYPO3\\CMS\\VisualEditor\\Backend\\Controller\\PageEditController\:\:\$selectedLanguages \(list\\) does not accept array\, TYPO3\\CMS\\Core\\Site\\Entity\\SiteLanguage\>\.$#' + identifier: assign.propertyType + count: 1 + path: Classes/Backend/Controller/PageEditController.php + - message: '#^Call to an undefined method PhpParser\\Node\\Expr\|PhpParser\\Node\\Identifier\:\:toString\(\)\.$#' identifier: method.notFound diff --git a/rector.php b/rector.php index 3630909..aceea48 100644 --- a/rector.php +++ b/rector.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Rector\Php71\Rector\FuncCall\RemoveExtraParametersRector; use Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector; use PLUS\GrumPHPConfig\RectorSettings; use Rector\Caching\ValueObject\Storage\FileCacheStorage; @@ -41,6 +42,10 @@ SafeDeclareStrictTypesRector::class => [ __DIR__ . '/ext_emconf.php', ], + RemoveExtraParametersRector::class => [ + // ignore this->contentFetcher->getFlatContentRecords and this->contentFetcher->getTranslationData in TYPO3 13 (TODO remove if TYPO3 14 is lowest supported version) + __DIR__ . '/Classes/Backend/Controller/PageEditController.php', + ], ], ); };