diff --git a/cms-ui/apps/editor-ui/e2e/device-preview.spec.ts b/cms-ui/apps/editor-ui/e2e/device-preview.spec.ts
index 87ae9f71c7..3a759736a6 100644
--- a/cms-ui/apps/editor-ui/e2e/device-preview.spec.ts
+++ b/cms-ui/apps/editor-ui/e2e/device-preview.spec.ts
@@ -159,6 +159,17 @@ test.describe('Device Preview', () => {
await expect(trigger).toBeVisible();
});
+ // The compare-mode visibility hide (Vorschau button suppressed when
+ // editorState.compareWithId is set or editMode is one of the
+ // COMPARE_VERSION_* values) is implemented in
+ // EditorToolbarComponent.determineVisibleButtons() and should be
+ // covered by either a Component-level unit test or the existing
+ // language-compare e2e flows when those land. It is intentionally
+ // not asserted here — driving the editor into compare-mode purely
+ // from this spec would require multi-language test data plus a
+ // brittle reach into Angular's injector from page.evaluate(),
+ // which the rest of the test suite avoids.
+
test('removes the legacy Vorschau entry from the kebab menu', async ({ page }) => {
const list = findList(page, ITEM_TYPE_PAGE);
const row = findItem(list, IMPORTER.get(PAGE_ONE).id);
@@ -319,26 +330,44 @@ test.describe('Device Preview', () => {
});
/* --------------------------------------------------------------------- *
- * Persistence
+ * URL state
* --------------------------------------------------------------------- */
- test.describe('Persistence', () => {
+ test.describe('URL state', () => {
- test('persists the most recently used preset to local storage', async ({ page }) => {
+ test('writes the active preset id to the URL as `?device=` query param', async ({ page }) => {
await openPageInPreview(page, IMPORTER.get(PAGE_ONE));
await pickDevicePreset(page, PRESET_TABLET.id);
await expect(page.locator(FRAME_WRAPPER)).toHaveAttribute('data-active-preset', PRESET_TABLET.id);
- // The DevicePreviewService persists via the `LocalStorage` wrapper
- // which prefixes its keys with `GCMSUI_`. The setting is
- // application-wide (`getForAllUsers`), so the unprefixed key is
- // `devicePreview.lastPresetId`.
- const stored = await page.evaluate(() => localStorage.getItem('GCMSUI_devicePreview.lastPresetId'));
+ // URL now carries `?device=tablet`
+ await expect(page).toHaveURL(/[?&]device=tablet(&|$)/);
+ });
+
+ test('removes the query param when "Volle Breite" is picked', async ({ page }) => {
+ await openPageInPreview(page, IMPORTER.get(PAGE_ONE));
+
+ await pickDevicePreset(page, PRESET_MOBILE.id);
+ await expect(page).toHaveURL(/[?&]device=mobile(&|$)/);
+
+ await pickDevicePreset(page, 'clear');
+ await expect(page).not.toHaveURL(/[?&]device=/);
+ });
- // LocalStorage wrapper stringifies values, so the expected
- // shape is the JSON-encoded preset id.
- expect(stored).toBe(JSON.stringify(PRESET_TABLET.id));
+ test('restores the active preset when reloading a URL with `?device=`', async ({ page }) => {
+ await openPageInPreview(page, IMPORTER.get(PAGE_ONE));
+
+ await pickDevicePreset(page, PRESET_DESKTOP.id);
+ const urlWithPreset = page.url();
+ expect(urlWithPreset).toMatch(/[?&]device=desktop(&|$)/);
+
+ // Hard-reload the URL to simulate the user opening a shared link.
+ await page.goto(urlWithPreset);
+ await page.locator('content-frame iframe.master-frame[loaded="true"]').waitFor({ timeout: 60_000 });
+
+ await expect(page.locator(FRAME_WRAPPER)).toHaveClass(/device-preview-active/);
+ await expect(page.locator(FRAME_WRAPPER)).toHaveAttribute('data-active-preset', PRESET_DESKTOP.id);
});
});
});
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.html b/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.html
index a2ff7374a8..f400bcc3d3 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.html
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.html
@@ -92,10 +92,10 @@
+ @if (devicePreviewActivePreset) {
+
+ {{ devicePreviewActivePreset.icon }}
+ {{ devicePreviewActivePreset.labelKey | gtxI18n }}
+ {{ devicePreviewActivePreset.width }} × {{ devicePreviewActivePreset.height }}
+
+ }
+
-
- @if (devicePreviewActive$ | async; as activePreset) {
-
- {{ activePreset.icon }}
- {{ activePreset.labelKey | gtxI18n }}
- {{ activePreset.width }} × {{ activePreset.height }}
-
- }
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.scss b/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.scss
index 9dcbec85ac..f120598691 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.scss
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.scss
@@ -53,53 +53,72 @@
// never re-mounted, which preserves Aloha state and the loaded URL.
&.device-preview-active {
background:
- linear-gradient(45deg, rgba(0,0,0,0.04) 25%, transparent 25%) 0 0 / 20px 20px,
- linear-gradient(-45deg, rgba(0,0,0,0.04) 25%, transparent 25%) 0 10px / 20px 20px,
- linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.04) 75%) 10px -10px / 20px 20px,
- linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.04) 75%) 10px 0 / 20px 20px,
+ linear-gradient(45deg, rgba($gtx-color-mid-gray, 0.18) 25%, transparent 25%) 0 0 / 1.25rem 1.25rem,
+ linear-gradient(-45deg, rgba($gtx-color-mid-gray, 0.18) 25%, transparent 25%) 0 0.625rem / 1.25rem 1.25rem,
+ linear-gradient(45deg, transparent 75%, rgba($gtx-color-mid-gray, 0.18) 75%) 0.625rem -0.625rem / 1.25rem 1.25rem,
+ linear-gradient(-45deg, transparent 75%, rgba($gtx-color-mid-gray, 0.18) 75%) 0.625rem 0 / 1.25rem 1.25rem,
$gtx-color-very-light-gray;
- padding: 16px;
+ padding: 1rem;
overflow: auto;
align-items: center;
.frame-language-container {
flex: 0 0 auto;
- width: var(--device-w, 100%);
- height: var(--device-h, 100%);
+ // Keep the device's native aspect ratio regardless of how
+ // much horizontal/vertical space the editor panel currently
+ // gives us. The CSS variables --device-w / --device-h are
+ // unitless numbers (set as bare numbers from the
+ // ContentFrameComponent template, see [style.--device-w]
+ // bindings) so they can be used both in the ratio and —
+ // multiplied by 1px — as length values.
+ aspect-ratio: var(--device-w) / var(--device-h);
+ width: calc(var(--device-w) * 1px);
+ height: auto;
+ // Cap to the available stage area; the browser shrinks the
+ // box proportionally because of the aspect-ratio rule above.
max-width: 100%;
- background: white;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
- border-radius: 6px;
+ max-height: 100%;
+ background: $gcms-color-page-background;
+ box-shadow: 0 0.5rem 2rem rgba($gtx-color-very-dark-gray, 0.18);
+ border-radius: 0.375rem;
overflow: hidden;
transition: width 0.25s ease, height 0.25s ease;
.frame {
flex: 1 1 auto;
width: 100%;
+ height: 100%;
}
}
}
.device-preview-info {
- position: absolute;
- top: 8px;
- left: 50%;
- transform: translateX(-50%);
+ // Sticks to the top of the scrollable .frame-wrapper while the
+ // user scrolls a tall device-frame (e.g. a 1024px-tall tablet
+ // inside a 600px-tall editor panel). The pill is the FIRST
+ // child after the loading indicators in the wrapper template,
+ // so its natural position is at the top — `position: sticky`
+ // then keeps it pinned there regardless of scroll position.
+ position: sticky;
+ top: 0.5rem;
+ align-self: center;
+ flex: 0 0 auto;
+ margin-bottom: 0.5rem;
background: $gtx-color-primary;
- color: white;
- padding: 4px 12px;
+ color: $gcms-color-page-background;
+ padding: 0.25rem 0.75rem;
border-radius: 999px;
- font-size: 12px;
+ font-size: 0.75rem;
font-weight: 500;
display: inline-flex;
align-items: center;
- gap: 8px;
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+ gap: 0.5rem;
+ box-shadow: 0 0.125rem 0.375rem rgba($gtx-color-very-dark-gray, 0.15);
z-index: 5;
pointer-events: none;
.device-preview-info-icon {
- font-size: 16px;
+ font-size: 1rem;
}
.device-preview-info-size {
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.ts b/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.ts
index 0f178fbca6..a9d1cc5143 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.ts
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/components/content-frame/content-frame.component.ts
@@ -196,11 +196,12 @@ export class ContentFrameComponent implements OnInit, AfterViewInit, OnDestroy {
isInQueue$: Observable
= undefined;
/**
- * Stream of the currently active device-preview preset (or null when off).
- * Bound directly in the template to constrain the iframe to mobile / tablet
- * / desktop viewport sizes via CSS custom properties.
+ * Currently active device-preview preset (or null when off). Mirrored
+ * from `DevicePreviewService.activePreset$` via a subscription set up
+ * in `ngOnInit`, so the template can read it as a plain property
+ * instead of opening async-pipe subscriptions on every render.
*/
- public devicePreviewActive$: Observable;
+ public devicePreviewActivePreset: DevicePreset | null = null;
private onLoadListener: EventListener;
public itemPermissions: ItemPermissions = noItemPermissions;
@@ -242,9 +243,7 @@ export class ContentFrameComponent implements OnInit, AfterViewInit, OnDestroy {
private aloha: AlohaIntegrationService,
private urlBuilder: ResourceUrlBuilder,
private devicePreview: DevicePreviewService,
- ) {
- this.devicePreviewActive$ = this.devicePreview.activePreset$;
- }
+ ) { }
ngOnInit(): void {
this.customScriptHostService.initialize(this);
@@ -256,6 +255,11 @@ export class ContentFrameComponent implements OnInit, AfterViewInit, OnDestroy {
return this.customerScriptService.createGCMSUIObject(this.customScriptHostService, iFrameWindow, iFrameDocument);
};
+ this.subscriptions.push(this.devicePreview.activePreset$.subscribe((preset) => {
+ this.devicePreviewActivePreset = preset;
+ this.changeDetector.markForCheck();
+ }));
+
this.subscriptions.push(
this.appState.select((state) => state.editor.contentModified).subscribe((modified) => {
this.contentModified = modified;
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/components/device-preview-menu/device-preview-menu.component.html b/cms-ui/apps/editor-ui/src/app/content-frame/components/device-preview-menu/device-preview-menu.component.html
index 7d7ea9fecc..6c41fadae2 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/components/device-preview-menu/device-preview-menu.component.html
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/components/device-preview-menu/device-preview-menu.component.html
@@ -9,10 +9,10 @@
[class.is-selected]="!activePresetId"
(click)="onClear()"
>
- aspect_ratio
+ aspect_ratio
{{ 'editor.device_preview_clear' | gtxI18n }}
@if (!activePresetId) {
- check
+ check
}
@@ -25,11 +25,11 @@
[class.is-selected]="activePresetId === preset.id"
(click)="onSelect(preset)"
>
- {{ preset.icon }}
+ {{ preset.icon }}
{{ preset.labelKey | gtxI18n }}
{{ preset.width }} × {{ preset.height }}
@if (activePresetId === preset.id) {
- check
+ check
}
}
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/components/device-preview-menu/device-preview-menu.component.scss b/cms-ui/apps/editor-ui/src/app/content-frame/components/device-preview-menu/device-preview-menu.component.scss
index 0e842ef325..c13836ec33 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/components/device-preview-menu/device-preview-menu.component.scss
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/components/device-preview-menu/device-preview-menu.component.scss
@@ -1,22 +1,24 @@
+@import "variables";
+
:host {
display: block;
- min-width: 240px;
+ min-width: 15rem;
}
.device-preview-menu {
.menu-header {
- padding: 8px 14px 4px 14px;
- font-size: 11px;
+ padding: 0.5rem 0.875rem 0.25rem 0.875rem;
+ font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
- color: rgba(0, 0, 0, 0.55);
+ color: $gtx-color-dark-gray;
}
.menu-divider {
height: 1px;
- background: rgba(0, 0, 0, 0.08);
- margin: 4px 0;
+ background: $gtx-color-light-gray;
+ margin: 0.25rem 0;
}
.device-preview-item,
@@ -30,23 +32,23 @@
}
.device-size {
- font-size: 11px;
- color: rgba(0, 0, 0, 0.55);
- margin-left: 8px;
+ font-size: 0.6875rem;
+ color: $gtx-color-dark-gray;
+ margin-left: 0.5rem;
white-space: nowrap;
}
.device-check {
- margin-left: 8px;
- font-size: 16px;
+ margin-left: 0.5rem;
+ font-size: 1rem;
}
&.is-selected {
- background: rgba(33, 150, 243, 0.08);
- color: #1976d2;
+ background: rgba($gtx-color-primary, 0.08);
+ color: $gtx-color-primary;
.device-size {
- color: rgba(25, 118, 210, 0.7);
+ color: rgba($gtx-color-primary, 0.7);
}
}
}
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.html b/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.html
index 400e812c1a..2d3872139b 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.html
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.html
@@ -132,7 +132,7 @@
class="primary-action device-preview-button"
data-action="device-preview"
data-context-id="device-preview"
- [class.is-active]="!!(devicePreviewActive$ | async)"
+ [class.is-active]="!!devicePreviewActivePreset"
>
- {{ (devicePreviewActive$ | async)?.icon || 'devices' }}
+ {{ devicePreviewActivePreset?.icon || 'devices' }}
- @if (devicePreviewActive$ | async; as activePreset) {
- {{ activePreset.labelKey | gtxI18n }}
+ @if (devicePreviewActivePreset) {
+ {{ devicePreviewActivePreset.labelKey | gtxI18n }}
} @else {
{{ 'editor.device_preview_label' | gtxI18n }}
}
@@ -154,8 +154,8 @@
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.scss b/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.scss
index 7c6c59e043..7523668cec 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.scss
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.scss
@@ -38,6 +38,11 @@
}
.device-preview-button {
+ // Spacing to whichever button comes before us in the
+ // .toolbar-actions row. Matches the existing margin convention
+ // used by other primary actions in this file.
+ margin-left: 0.5rem;
+
// The `` inside handles its own flex layout; we only
// tweak the caret + label cosmetics here. Vertical centering is
// achieved via inline-flex on the inner spans/icons so that the
@@ -65,13 +70,13 @@
&.is-active {
gtx-button .button-event-wrapper button,
gtx-button button {
- background-color: rgba(25, 118, 210, 0.1);
- color: #1565c0;
+ background-color: rgba($gtx-color-primary, 0.1);
+ color: $gtx-color-primary;
}
.device-preview-caret {
opacity: 1;
- color: #1565c0;
+ color: $gtx-color-primary;
}
}
}
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.ts b/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.ts
index d5db1e27b3..a5ba5c7289 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.ts
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/components/editor-toolbar/editor-toolbar.component.ts
@@ -118,10 +118,15 @@ export class EditorToolbarComponent implements OnInit, OnChanges, OnDestroy {
public uploadInProgress$: Observable;
public alohaReady: boolean;
- /** Streams from the DevicePreviewService — wired in ngOnInit. */
- public devicePreviewState$: Observable;
- public devicePreviewActive$: Observable;
- public devicePreviewPresets$: Observable;
+ /**
+ * Mirror of `DevicePreviewService` state, kept as plain properties so
+ * the template doesn't have to subscribe via async pipes (which would
+ * each open their own subscription on every change-detection run).
+ * Wired in `ngOnInit` and torn down via `subscriptions`.
+ */
+ public devicePreviewState: DevicePreviewState = { active: false, presetId: null };
+ public devicePreviewActivePreset: DevicePreset | null = null;
+ public devicePreviewPresets: DevicePreset[] = [];
public breadcrumbs: (IBreadcrumbLink | IBreadcrumbRouterLink)[] = [];
public multilineExpanded: boolean;
@@ -152,9 +157,18 @@ export class EditorToolbarComponent implements OnInit, OnChanges, OnDestroy {
) {}
ngOnInit(): void {
- this.devicePreviewState$ = this.devicePreview.state$;
- this.devicePreviewActive$ = this.devicePreview.activePreset$;
- this.devicePreviewPresets$ = this.devicePreview.presets$;
+ this.subscriptions.push(this.devicePreview.state$.subscribe((state) => {
+ this.devicePreviewState = state;
+ this.changeDetector.markForCheck();
+ }));
+ this.subscriptions.push(this.devicePreview.activePreset$.subscribe((preset) => {
+ this.devicePreviewActivePreset = preset;
+ this.changeDetector.markForCheck();
+ }));
+ this.subscriptions.push(this.devicePreview.presets$.subscribe((presets) => {
+ this.devicePreviewPresets = presets;
+ this.changeDetector.markForCheck();
+ }));
this.uploadInProgress$ = this.appState.select((state) => state.editor).pipe(
map((editorState) => editorState.uploadInProgress),
@@ -197,14 +211,13 @@ export class EditorToolbarComponent implements OnInit, OnChanges, OnDestroy {
this.changeDetector.markForCheck();
}));
- // Auto-deactivate device-preview when the user leaves PREVIEW mode
- // (e.g. clicks "Bearbeiten" to start editing). Without this the user
- // would end up editing in a 375px frame with Aloha toolbars cropped.
- this.subscriptions.push(this.appState.select((state) => state.editor.editMode).subscribe((editMode) => {
- if (editMode !== EditMode.PREVIEW && this.devicePreview.currentState.active) {
- this.devicePreview.deactivate();
- }
- }));
+ // No explicit auto-deactivation needed: device-preview state is
+ // derived from the URL's `?device=` query parameter via
+ // DevicePreviewService. Whenever the user leaves PREVIEW mode (e.g.
+ // by clicking BEARBEITEN), the navigation drops the query param,
+ // and the service's NavigationEnd subscriber resets the state. So
+ // "no edit mode in mobile frame" comes for free from the routing
+ // layer.
this.setUpBreadcrumbs(this.currentItem, this.currentNode?.id);
this.checkIfInQueue();
@@ -377,44 +390,50 @@ export class EditorToolbarComponent implements OnInit, OnChanges, OnDestroy {
.navigate();
}
- /**
- * Switches the editor from EDIT to PREVIEW mode if currently editing.
- * Both `selectDevicePreset` and `clearDevicePreset` route through this so
- * any interaction with the Vorschau dropdown lands the user in PREVIEW.
- */
- private ensurePreviewMode(): void {
- const editMode = this.editorState?.editMode;
- if (editMode === EditMode.EDIT && this.currentNode && this.currentItem) {
- this.navigationService
- .detailOrModal(this.currentNode.id, this.currentItem.type, this.currentItem.id, EditMode.PREVIEW)
- .navigate();
- }
- }
-
/**
* Activate device-preview mode for the given preset (mobile / tablet / …).
* The content-frame iframe will be visually constrained to this size.
*
- * If the editor is currently in EDIT mode, this also switches to PREVIEW —
- * editing inside a 375px viewport with Aloha toolbars overflowing is
- * disorienting, and device-preview is conceptually a "view as end-user"
- * mode. Return to editing via the BEARBEITEN button (which auto-
- * deactivates the device preset, see ngOnInit).
+ * If the editor is currently in EDIT mode, switching mode and writing the
+ * `?device=` query parameter is performed in a single router navigation
+ * so the iframe only reloads once. Otherwise the service updates the
+ * query parameter directly, leaving the path unchanged.
*/
public selectDevicePreset(preset: DevicePreset): void {
- this.ensurePreviewMode();
- this.devicePreview.activate(preset.id);
+ this.dispatchDevicePreview(preset.id);
}
/**
- * Deactivate device-preview mode and return to full-width rendering.
- * If the user picks "Volle Breite" while still in EDIT mode, this also
- * switches to PREVIEW so the behaviour is consistent across all
- * dropdown entries — every Vorschau interaction lands in PREVIEW.
+ * Deactivate device-preview mode (full-width rendering). Like selection,
+ * picking "Volle Breite" while in EDIT also switches to PREVIEW, so any
+ * interaction with the Vorschau dropdown lands the user there.
*/
public clearDevicePreset(): void {
- this.ensurePreviewMode();
- this.devicePreview.deactivate();
+ this.dispatchDevicePreview(null);
+ }
+
+ /**
+ * Single entry point for both selection and clearing. Combines the
+ * EDIT→PREVIEW switch (if needed) with the device query parameter
+ * update into one navigation, then falls back to the service when the
+ * user is already in PREVIEW.
+ */
+ private dispatchDevicePreview(presetId: string | null): void {
+ const inEdit = this.editorState?.editMode === EditMode.EDIT;
+ if (inEdit && this.currentNode && this.currentItem) {
+ this.navigationService
+ .detailOrModal(this.currentNode.id, this.currentItem.type, this.currentItem.id, EditMode.PREVIEW)
+ .navigate({
+ queryParams: { device: presetId },
+ queryParamsHandling: 'merge',
+ });
+ return;
+ }
+ if (presetId) {
+ this.devicePreview.activate(presetId);
+ } else {
+ this.devicePreview.deactivate();
+ }
}
editInheritance(): void {
@@ -473,8 +492,18 @@ export class EditorToolbarComponent implements OnInit, OnChanges, OnDestroy {
const canPublish = !!(isPage || (isForm && (this.itemPermissions as FormPermissions)?.publish));
// Device-preview is meaningful while we're looking at the rendered
- // iframe (preview or edit mode) of a page or form.
- const showDevicePreview = (isPage || isForm) && (previewing || editing) && userCan.view;
+ // iframe (preview or edit mode) of a page or form. It is suppressed
+ // during version- or language-comparison views, because there are
+ // two iframes side by side in those modes and the device-frame
+ // wrapper around a single one would only constrain the master
+ // iframe — confusing UX.
+ const isComparing = !!this.editorState?.compareWithId
+ || editMode === EditMode.COMPARE_VERSION_SOURCES
+ || editMode === EditMode.COMPARE_VERSION_CONTENTS;
+ const showDevicePreview = (isPage || isForm)
+ && (previewing || editing)
+ && !isComparing
+ && userCan.view;
return {
compareContents: (isPage || isForm) && editMode === EditMode.COMPARE_VERSION_SOURCES,
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/providers/device-preview/device-preview.service.spec.ts b/cms-ui/apps/editor-ui/src/app/content-frame/providers/device-preview/device-preview.service.spec.ts
index 3ad03b4d0f..3bb6c75bc5 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/providers/device-preview/device-preview.service.spec.ts
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/providers/device-preview/device-preview.service.spec.ts
@@ -1,60 +1,120 @@
import { TestBed } from '@angular/core/testing';
-import { LocalStorage } from '../../../core/providers/local-storage/local-storage.service';
+import { NavigationEnd, Router } from '@angular/router';
+import { Subject } from 'rxjs';
import {
DEFAULT_DEVICE_PRESETS,
- DEVICE_PREVIEW_LAST_PRESET_KEY,
+ DEVICE_PREVIEW_QUERY_PARAM,
DevicePreviewService,
} from './device-preview.service';
-class MockLocalStorage {
- private store = new Map();
- getForAllUsers(key: string): any {
- return this.store.has(key) ? this.store.get(key) : null;
+class MockRouter {
+ public events = new Subject();
+ public url = '/editor/1/page/2/preview';
+ public navigateByUrlCalls: { tree: any; extras?: any }[] = [];
+
+ parseUrl(url: string): any {
+ const [path, query = ''] = url.split('?');
+ const queryParams: Record = {};
+ if (query) {
+ for (const part of query.split('&')) {
+ const [k, v] = part.split('=');
+ if (k) {
+ queryParams[decodeURIComponent(k)] = decodeURIComponent(v ?? '');
+ }
+ }
+ }
+ return {
+ path,
+ queryParams,
+ toString(): string {
+ const qp = Object.entries(this.queryParams)
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v as string)}`)
+ .join('&');
+ return qp ? `${this.path}?${qp}` : this.path;
+ },
+ };
}
- setForAllUsers(key: string, value: any): void {
- this.store.set(key, value);
+
+ navigateByUrl(tree: any, extras?: any): Promise {
+ this.navigateByUrlCalls.push({ tree, extras });
+ const newUrl = tree.toString();
+ this.url = newUrl;
+ // Emit a NavigationEnd to mimic real router behaviour
+ this.events.next(new NavigationEnd(1, newUrl, newUrl));
+ return Promise.resolve(true);
}
}
describe('DevicePreviewService', () => {
let service: DevicePreviewService;
- let storage: MockLocalStorage;
+ let router: MockRouter;
- beforeEach(() => {
- storage = new MockLocalStorage();
+ function createService(initialUrl: string = '/editor/1/page/2/preview'): void {
+ router = new MockRouter();
+ router.url = initialUrl;
TestBed.configureTestingModule({
providers: [
DevicePreviewService,
- { provide: LocalStorage, useValue: storage },
+ { provide: Router, useValue: router },
],
});
service = TestBed.inject(DevicePreviewService);
+ }
+
+ afterEach(() => {
+ service?.ngOnDestroy?.();
});
- it('starts deactivated with default presets exposed', () => {
+ it('starts deactivated when no device query param is present in the URL', () => {
+ createService('/editor/1/page/2/preview');
expect(service.currentState).toEqual({ active: false, presetId: null });
expect(service.presets.length).toBe(DEFAULT_DEVICE_PRESETS.length);
});
- it('activates a preset and persists it to local storage', () => {
+ it('seeds initial state from a `?device=` query param on the current URL', () => {
+ createService('/editor/1/page/2/preview?device=tablet');
+ expect(service.currentState).toEqual({ active: true, presetId: 'tablet' });
+ });
+
+ it('ignores an unknown preset id in the URL', () => {
+ createService('/editor/1/page/2/preview?device=xxx');
+ expect(service.currentState).toEqual({ active: false, presetId: null });
+ });
+
+ it('activate() writes the preset id into the URL via Router.navigateByUrl', () => {
+ createService();
service.activate('mobile');
+ const last = router.navigateByUrlCalls.at(-1);
+ expect(last?.tree.queryParams[DEVICE_PREVIEW_QUERY_PARAM]).toBe('mobile');
expect(service.currentState).toEqual({ active: true, presetId: 'mobile' });
- expect(storage.getForAllUsers(DEVICE_PREVIEW_LAST_PRESET_KEY)).toBe('mobile');
});
- it('ignores activation requests for unknown preset ids', () => {
+ it('activate() ignores an unknown preset id', () => {
+ createService();
service.activate('does-not-exist');
+ expect(router.navigateByUrlCalls.length).toBe(0);
expect(service.currentState).toEqual({ active: false, presetId: null });
});
- it('deactivate() returns to inactive state', () => {
- service.activate('tablet');
+ it('deactivate() removes the device query param from the URL', () => {
+ createService('/editor/1/page/2/preview?device=tablet');
+ expect(service.currentState.active).toBe(true);
service.deactivate();
+ const last = router.navigateByUrlCalls.at(-1);
+ expect(last?.tree.queryParams[DEVICE_PREVIEW_QUERY_PARAM]).toBeUndefined();
expect(service.currentState).toEqual({ active: false, presetId: null });
});
+ it('deactivate() is a no-op when device-preview is already off', () => {
+ createService('/editor/1/page/2/preview');
+ service.deactivate();
+ expect(router.navigateByUrlCalls.length).toBe(0);
+ });
+
it('toggle() switches the same preset off and a different preset on', () => {
+ createService();
+
service.toggle('desktop');
expect(service.currentState.active).toBe(true);
expect(service.currentState.presetId).toBe('desktop');
@@ -66,18 +126,23 @@ describe('DevicePreviewService', () => {
expect(service.currentState.presetId).toBe('mobile');
});
- it('activateLastUsed() restores the persisted preset', () => {
- storage.setForAllUsers(DEVICE_PREVIEW_LAST_PRESET_KEY, 'tablet');
- const restored = service.activateLastUsed();
- expect(restored).toBe(true);
+ it('reflects URL changes coming from the router (browser back/forward)', () => {
+ createService('/editor/1/page/2/preview?device=tablet');
expect(service.currentState.presetId).toBe('tablet');
- });
- it('activateLastUsed() returns false when no preset was persisted', () => {
- expect(service.activateLastUsed()).toBe(false);
+ // Simulate the user navigating back to a URL without the device param
+ router.url = '/editor/1/page/2/preview';
+ router.events.next(new NavigationEnd(2, router.url, router.url));
+ expect(service.currentState).toEqual({ active: false, presetId: null });
+
+ // Simulate forward to a URL with a different preset
+ router.url = '/editor/1/page/2/preview?device=mobile';
+ router.events.next(new NavigationEnd(3, router.url, router.url));
+ expect(service.currentState).toEqual({ active: true, presetId: 'mobile' });
});
it('activePreset$ emits null while inactive and the resolved preset while active', (done) => {
+ createService();
const emissions: (string | null)[] = [];
service.activePreset$.subscribe(p => emissions.push(p?.id ?? null));
diff --git a/cms-ui/apps/editor-ui/src/app/content-frame/providers/device-preview/device-preview.service.ts b/cms-ui/apps/editor-ui/src/app/content-frame/providers/device-preview/device-preview.service.ts
index f18caad0ac..4f089a1469 100644
--- a/cms-ui/apps/editor-ui/src/app/content-frame/providers/device-preview/device-preview.service.ts
+++ b/cms-ui/apps/editor-ui/src/app/content-frame/providers/device-preview/device-preview.service.ts
@@ -1,7 +1,7 @@
-import { Injectable } from '@angular/core';
-import { BehaviorSubject, Observable } from 'rxjs';
-import { distinctUntilChanged, map } from 'rxjs/operators';
-import { LocalStorage } from '../../../core/providers/local-storage/local-storage.service';
+import { Injectable, OnDestroy } from '@angular/core';
+import { NavigationEnd, Router } from '@angular/router';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
+import { distinctUntilChanged, filter, map } from 'rxjs/operators';
import { DevicePreset, DevicePreviewState } from '../../models/device-preset';
/**
@@ -15,24 +15,28 @@ export const DEFAULT_DEVICE_PRESETS: ReadonlyArray = Object.freeze
{ id: 'desktop-xl', labelKey: 'editor.device_desktop_xl_label', icon: 'tv', width: 1600, height: 960 },
]);
-/** LocalStorage key for the most recently used preset id. */
-export const DEVICE_PREVIEW_LAST_PRESET_KEY = 'devicePreview.lastPresetId';
+/** Query-parameter name carrying the active preset id in the URL. */
+export const DEVICE_PREVIEW_QUERY_PARAM = 'device';
/**
* Holds the device-preview state (active flag + selected preset) and exposes
- * it as observables so the editor-toolbar and content-frame components stay in
- * sync.
+ * it as observables so the editor-toolbar and content-frame components stay
+ * in sync.
*
- * Persistence: the most recently used preset is stored via the existing
- * `LocalStorage` wrapper so the user's last choice survives a reload.
+ * **State source of truth: the URL.** The active preset id lives as the
+ * `?device=` query parameter on the editor route. Reasons:
+ * - shareable links carry the user's preview format,
+ * - browser back/forward navigates between formats naturally,
+ * - no local persistence layer is needed (no UserSettings entry, no
+ * LocalStorage handling, no per-user/per-tab divergence).
*
- * Note on architecture: kept intentionally lightweight (BehaviorSubject) so
- * it does not pull in the global NgRx state graph for a UI-only concern.
- * If CMS-wide configurable presets become a requirement later, the `presets$`
- * observable is the natural extension point.
+ * The service mirrors the URL into its `state$` BehaviorSubject so that
+ * consumers don't have to deal with `ActivatedRoute` themselves. Mutations
+ * are performed via Angular's `Router`, which means they integrate cleanly
+ * with the rest of the editor's navigation flow.
*/
@Injectable({ providedIn: 'root' })
-export class DevicePreviewService {
+export class DevicePreviewService implements OnDestroy {
private readonly stateSubject = new BehaviorSubject({
active: false,
@@ -43,6 +47,8 @@ export class DevicePreviewService {
[...DEFAULT_DEVICE_PRESETS],
);
+ private routerSubscription: Subscription;
+
/** Stream of state changes (active flag, selected preset id). */
public readonly state$: Observable = this.stateSubject.asObservable();
@@ -58,7 +64,22 @@ export class DevicePreviewService {
distinctUntilChanged((a, b) => a?.id === b?.id),
);
- constructor(private localStorage: LocalStorage) {}
+ constructor(private router: Router) {
+ // Seed initial state from the current URL.
+ this.syncStateFromUrl(this.router.url);
+
+ // Keep state in sync with future URL changes (programmatic navigation,
+ // browser back/forward, links).
+ this.routerSubscription = this.router.events.pipe(
+ filter((e): e is NavigationEnd => e instanceof NavigationEnd),
+ ).subscribe((event) => {
+ this.syncStateFromUrl(event.urlAfterRedirects);
+ });
+ }
+
+ public ngOnDestroy(): void {
+ this.routerSubscription?.unsubscribe();
+ }
/** Returns the latest synchronously-known state. */
public get currentState(): DevicePreviewState {
@@ -74,22 +95,29 @@ export class DevicePreviewService {
return this.presetsSubject.value.find(p => p.id === presetId);
}
- /** Activate device-preview with the given preset and persist the choice. */
+ /**
+ * Activate device-preview with the given preset by writing the preset id
+ * into the URL's `?device=` query parameter. The state is then updated
+ * via the router subscription.
+ *
+ * No-op if the preset id is unknown.
+ */
public activate(presetId: string): void {
- const preset = this.findPreset(presetId);
- if (!preset) {
+ if (!this.findPreset(presetId)) {
return;
}
- this.stateSubject.next({ active: true, presetId });
- this.persistLastPresetId(presetId);
+ this.updateDeviceQueryParam(presetId);
}
- /** Turn device-preview off (returns to full-width content-frame). */
+ /**
+ * Turn device-preview off by removing the `?device=` query parameter from
+ * the URL. The state is then updated via the router subscription.
+ */
public deactivate(): void {
if (!this.stateSubject.value.active) {
return;
}
- this.stateSubject.next({ active: false, presetId: null });
+ this.updateDeviceQueryParam(null);
}
/**
@@ -105,30 +133,34 @@ export class DevicePreviewService {
}
}
- /** Returns the most recently used preset id, or null. */
- public getLastUsedPresetId(): string | null {
- const value = this.localStorage.getForAllUsers(DEVICE_PREVIEW_LAST_PRESET_KEY);
- return typeof value === 'string' ? value : null;
- }
+ /* --------------------------------------------------------------------- *
+ * Internal helpers
+ * --------------------------------------------------------------------- */
- /**
- * Activate the last-used preset if any was persisted; otherwise no-op.
- * Useful when the user clicks the preview-button without picking a format.
- */
- public activateLastUsed(): boolean {
- const last = this.getLastUsedPresetId();
- if (last && this.findPreset(last)) {
- this.activate(last);
- return true;
+ private syncStateFromUrl(url: string): void {
+ const tree = this.router.parseUrl(url || '');
+ const raw = tree.queryParams?.[DEVICE_PREVIEW_QUERY_PARAM];
+ const presetId = typeof raw === 'string' ? raw : null;
+
+ const next: DevicePreviewState = presetId && this.findPreset(presetId)
+ ? { active: true, presetId }
+ : { active: false, presetId: null };
+
+ const current = this.stateSubject.value;
+ if (current.active === next.active && current.presetId === next.presetId) {
+ return;
}
- return false;
+ this.stateSubject.next(next);
}
- private persistLastPresetId(id: string): void {
- try {
- this.localStorage.setForAllUsers(DEVICE_PREVIEW_LAST_PRESET_KEY, id);
- } catch {
- // ignore — persistence is a nice-to-have, not critical
+ private updateDeviceQueryParam(presetId: string | null): void {
+ const tree = this.router.parseUrl(this.router.url);
+ if (presetId) {
+ tree.queryParams = { ...tree.queryParams, [DEVICE_PREVIEW_QUERY_PARAM]: presetId };
+ } else {
+ const { [DEVICE_PREVIEW_QUERY_PARAM]: _omit, ...rest } = tree.queryParams || {};
+ tree.queryParams = rest;
}
+ this.router.navigateByUrl(tree, { replaceUrl: true });
}
}