Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions cms-ui/apps/editor-ui/e2e/device-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@
<div
class="frame-wrapper"
[class.hidden]="editMode === EditMode.EDIT_PROPERTIES || editMode === EditMode.EDIT_INHERITANCE || currentItem?.type === 'form'"
[class.device-preview-active]="!!(devicePreviewActive$ | async)"
[attr.data-active-preset]="(devicePreviewActive$ | async)?.id"
[style.--device-w.px]="(devicePreviewActive$ | async)?.width"
[style.--device-h.px]="(devicePreviewActive$ | async)?.height"
[class.device-preview-active]="!!devicePreviewActivePreset"
[attr.data-active-preset]="devicePreviewActivePreset?.id"
[style.--device-w]="devicePreviewActivePreset?.width"
[style.--device-h]="devicePreviewActivePreset?.height"
>
<gtx-progress-bar
class="frame-requesting"
Expand All @@ -118,6 +118,14 @@
[longerMessage]="'editor.loading_takes_more_time' | gtxI18n"
></gtx-loading-spinner>

@if (devicePreviewActivePreset) {
<div class="device-preview-info">
<icon class="device-preview-info-icon">{{ devicePreviewActivePreset.icon }}</icon>
<span class="device-preview-info-label">{{ devicePreviewActivePreset.labelKey | gtxI18n }}</span>
<span class="device-preview-info-size">{{ devicePreviewActivePreset.width }} × {{ devicePreviewActivePreset.height }}</span>
</div>
}

<div class="frame-language-container" [class.hidden]="!comparePageId">
<iframe
#diffFrame
Expand Down Expand Up @@ -146,14 +154,6 @@

<div class="frame-language-indicator" [class.hidden]="!comparePageId">{{ itemLanguage?.name }}</div>
</div>

@if (devicePreviewActive$ | async; as activePreset) {
<div class="device-preview-info">
<icon class="device-preview-info-icon">{{ activePreset.icon }}</icon>
<span class="device-preview-info-label">{{ activePreset.labelKey | gtxI18n }}</span>
<span class="device-preview-info-size">{{ activePreset.width }} × {{ activePreset.height }}</span>
</div>
}
</div>

<div class="hidden-file-picker" #filePickerWrapper>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,12 @@ export class ContentFrameComponent implements OnInit, AfterViewInit, OnDestroy {
isInQueue$: Observable<boolean> = 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<DevicePreset | null>;
public devicePreviewActivePreset: DevicePreset | null = null;

private onLoadListener: EventListener;
public itemPermissions: ItemPermissions = noItemPermissions;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
[class.is-selected]="!activePresetId"
(click)="onClear()"
>
<icon left>aspect_ratio</icon>
<icon left class="hollow">aspect_ratio</icon>
<span class="device-label">{{ 'editor.device_preview_clear' | gtxI18n }}</span>
@if (!activePresetId) {
<icon class="device-check" right>check</icon>
<icon class="device-check hollow" right>check</icon>
}
</gtx-dropdown-item>

Expand All @@ -25,11 +25,11 @@
[class.is-selected]="activePresetId === preset.id"
(click)="onSelect(preset)"
>
<icon left>{{ preset.icon }}</icon>
<icon left class="hollow">{{ preset.icon }}</icon>
<span class="device-label">{{ preset.labelKey | gtxI18n }}</span>
<span class="device-size">{{ preset.width }} × {{ preset.height }}</span>
@if (activePresetId === preset.id) {
<icon class="device-check" right>check</icon>
<icon class="device-check hollow" right>check</icon>
}
</gtx-dropdown-item>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<gtx-dropdown-trigger>
<gtx-button
Expand All @@ -141,10 +141,10 @@
[title]="'editor.device_preview_tooltip' | gtxI18n"
data-context-trigger
>
<icon left>{{ (devicePreviewActive$ | async)?.icon || 'devices' }}</icon>
<icon left>{{ devicePreviewActivePreset?.icon || 'devices' }}</icon>
<span class="device-preview-button-label">
@if (devicePreviewActive$ | async; as activePreset) {
{{ activePreset.labelKey | gtxI18n }}
@if (devicePreviewActivePreset) {
{{ devicePreviewActivePreset.labelKey | gtxI18n }}
} @else {
{{ 'editor.device_preview_label' | gtxI18n }}
}
Expand All @@ -154,8 +154,8 @@
</gtx-dropdown-trigger>
<gtx-dropdown-content data-context-id="device-preview">
<gtx-device-preview-menu
[presets]="devicePreviewPresets$ | async"
[activePresetId]="(devicePreviewState$ | async)?.presetId"
[presets]="devicePreviewPresets"
[activePresetId]="devicePreviewState.presetId"
(select)="selectDevicePreset($event)"
(clear)="clearDevicePreset()"
></gtx-device-preview-menu>
Expand Down
Loading