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
1 change: 1 addition & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
- Added background uploads in the Assets index, which continue during control panel navigation, with persistent progress, retry, cancellation, and filename-conflict controls.
- Images uploaded from the Assets index that look like copies of images that are already uploaded, even resized or recompressed, now wait within their upload notification, which shows the existing images’ chips and offers to cancel the upload, keep both, or replace the existing image’s file, like filename conflicts. ([#20113](https://github.com/craftcms/cms/pull/20113))
- Added Undo/Redo support and a “Revert to original” button to the image editor. ([#19600](https://github.com/craftcms/cms/pull/19600))
- The image editor now asks for confirmation in a dialog before discarding unsaved changes when it’s closed or reset to the original. ([#20466](https://github.com/craftcms/cms/pull/20466))
- Image thumbnails and previews in the control panel now show a blurred placeholder based on the image’s colors while they load. ([#19750](https://github.com/craftcms/cms/pull/19750))
- Asset previews now have “Download” and “Close” buttons, and text file previews have a “Copy” button. Image previews’ focal point toggle is now a “Focal Point” toggle button beside them, rather than a button laid over the image that only appeared on hover. ([#20229](https://github.com/craftcms/cms/pull/20229))
- Volumes now have a “Propagation Method” setting, which can be set to “Let each asset choose which sites it should be saved to”. New volumes default to it, as do existing volumes on single-site installs, while existing volumes on multi-site installs continue to save assets to all sites. Assets are now initially saved to the site they were uploaded from, and can be deleted for individual sites. ([#20060](https://github.com/craftcms/cms/pull/20060))
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@
- Asset folders’ labels are now links in thumbnail and card views. ([#19942](https://github.com/craftcms/cms/pull/19942))
- Asset previews’ background gradients are now more transparent, and asset edit pages now have a hairline below the preview. ([#19942](https://github.com/craftcms/cms/pull/19942))
- The image editor’s header, tabs, controls, and footer now share the control panel’s standard container padding.
- The image editor now asks in a control panel dialog, rather than the browser’s own confirmation prompt, before discarding changes when it’s closed or reset to the original. ([#20466](https://github.com/craftcms/cms/pull/20466))
- Asset conditions, such as for index filters and Assets fields’ sources, now have an “Orientation” rule, which filters images by whether they’re landscape, portrait, or square. Images whose width and height differ by up to 2% count as square. ([#20114](https://github.com/craftcms/cms/pull/20114))
- Asset edit pages’ Info panels now show what was found in images, apart from the rest of their metadata: their type, a bar of their main colors, each as wide as the share of the image it covers, and embedded metadata, such as their creator, copyright notice, the date they were taken, their camera, and a map of where they were taken, with a link to Google Maps. The panels’ “Location” item, and asset indexes’ “Location” column and card attribute, which show an asset’s folder, are now “Folder”. ([#20103](https://github.com/craftcms/cms/pull/20103))
- Asset indexes can now show “Image Type”, “Colors”, “Creator”, “Credit”, “Copyright”, “Date Taken”, “Camera”, and “Embedded Keywords” columns and card attributes. ([#20103](https://github.com/craftcms/cms/pull/20103))
Expand Down
113 changes: 95 additions & 18 deletions resources/js/modules/image-editor/components/ImageEditorDialog.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {computed, ref, useId, useTemplateRef, watch} from 'vue';
import {computed, nextTick, ref, useId, useTemplateRef, watch} from 'vue';
import {commandShortcut, usesCommandKey} from '@/common/utils/keyShortcuts';
import {useShortcut} from '@/common/shortcuts';
import {t} from '@craftcms/ui';
Expand Down Expand Up @@ -208,10 +208,56 @@
? commandShortcut('Z', {shift: true})
: `${commandShortcut('Y')} ${commandShortcut('Z', {shift: true})}`;

/**
* The edit that waits on the person saying yes: closing the editor, or
* resetting the image. Kept while the confirmation closes, so its text stays
* put until it's gone.
*/
const confirming = ref<'close' | 'reset' | null>(null);
const confirmOpen = ref(false);
/** Set by the confirm button; the edit runs once the confirmation has closed. */
let confirmed = false;

function askToConfirm(action: 'close' | 'reset'): void {
confirming.value = action;
confirmed = false;
confirmOpen.value = true;
}

function onConfirm(): void {
confirmed = true;
confirmOpen.value = false;
}

/**
* Runs the confirmed edit only after the confirmation has handed focus back,
* so closing the editor returns focus to what opened it rather than into a
* dialog that has already gone.
*/
async function onConfirmationHidden(): Promise<void> {
if (!confirmed) {
return;
}

confirmed = false;

if (confirming.value === 'close') {
opened.value = false;
return;
}

resetEditor();

// The reset button disables itself now there's nothing to reset, which
// would drop focus to the page.
await nextTick();
tabsEl.value?.querySelector<HTMLElement>('craft-tab[selected]')?.focus();
}

/** Undo and redo, while the dialog is open. A field keeps its own undo for the text being typed into it. */
const historyShortcut = {
group: () => t('Image editor'),
isAvailable: () => opened.value,
isAvailable: () => opened.value && !confirmOpen.value,
ignoreInputs: true,
};
useShortcut('Mod+Z', () => editor.undo(), {
Expand All @@ -224,12 +270,8 @@
});
useShortcut('Control+Y', () => editor.redo(), historyShortcut);

/** Discards every edit, after asking. The dialog stays open. */
function onReset(): void {
if (!window.confirm(t('Discard all changes to this image?'))) {
return;
}

/** Discards every edit. The dialog stays open. */
function resetEditor(): void {
editor.reset();

// The controls hold their own copy of what they last applied, so they follow
Expand All @@ -246,13 +288,9 @@
* button, Escape and the backdrop — so this asks once for all of them.
*/
function onBeforeHide(event: Event): void {
if (
editor.isDirty.value &&
!window.confirm(
t('Any changes will be lost if you close the image editor.')
)
) {
if (editor.isDirty.value) {
event.preventDefault();
askToConfirm('close');
}
}

Expand Down Expand Up @@ -402,6 +440,7 @@

<template>
<craft-dialog
class="image-editor-dialog"
data-theme="dark"
fullscreen
.opened="opened"
Expand Down Expand Up @@ -740,7 +779,7 @@
type="button"
icon="arrow-rotate-left"
.disabled="!editor.isDirty.value"
@click="onReset"
@click="askToConfirm('reset')"
>
{{ t('Reset to original') }}
</craft-button>
Expand All @@ -766,20 +805,58 @@
</div>
</div>
</craft-dialog>

<!-- A sibling rather than a child: its lifecycle events bubble, and the
editor's own dialog would take them for its own. -->
<craft-dialog
data-theme="dark"
:label="
confirming === 'reset' ? t('Reset to original') : t('Discard changes')
"
.opened="confirmOpen"
@craft-hide="confirmOpen = false"
@craft-after-hide="onConfirmationHidden"
>
<p class="m-0">
{{
confirming === 'reset'
? t('Discard all changes to this image?')
: t('Any changes will be lost if you close the image editor.')
}}
</p>
<craft-button
slot="footer"
type="button"
autofocus
@click="confirmOpen = false"
>
{{ t('Cancel') }}
</craft-button>
<craft-button
slot="footer"
type="button"
variant="danger"
@click="onConfirm"
>
{{
confirming === 'reset' ? t('Reset to original') : t('Discard changes')
}}
</craft-button>
</craft-dialog>
</template>

<style scoped lang="css">
/* Every ancestor of `.image` needs a definite height: the editor measures it
to size the canvas. The body is edge to edge; header and footer keep their
padding. */
craft-dialog::part(body) {
.image-editor-dialog::part(body) {
padding: 0;
}

/* The header, tab strip, panels and footer share the CP's container padding,
so their contents line up along both edges. */
craft-dialog::part(header),
craft-dialog::part(footer),
.image-editor-dialog::part(header),
.image-editor-dialog::part(footer),
.image-editor__sidebar craft-tabs::part(strip) {
padding-inline: var(--cp-container-padding);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {afterEach, expect, it, vi} from 'vite-plus/test';
import {ref} from 'vue';
import '@craftcms/ui/components/dialog/dialog';
import '@craftcms/ui/components/button/button';
import type CraftDialog from '@craftcms/ui/components/dialog/dialog';
import {openImageEditorDialog} from './open-image-editor-dialog';

vi.mock('./useImageEditor', () => ({
useImageEditor: () => ({
state: {
currentView: ref('rotate'),
imageStraightenAngle: ref(0),
focalPoint: ref(null),
},
editing: {rectanglePickedUp: ref(false), pickedHandle: ref(null)},
isDirty: ref(true),
isReady: ref(true),
canUndo: ref(true),
canRedo: ref(false),
isSaving: ref(false),
savingAs: ref(null),
cursor: ref('default'),
setUiAdapter: () => {},
start: () => {},
}),
}));

afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});

function openDialogs(): CraftDialog[] {
return [...document.querySelectorAll<CraftDialog>('craft-dialog')].filter(
(dialog) => dialog.opened
);
}

async function settle(): Promise<void> {
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve));
}
}

function clickButton(label: string): void {
const dialog = openDialogs().at(-1)!;
const button = [...dialog.querySelectorAll('craft-button')].find(
(candidate) => candidate.textContent?.trim() === label
);

(button as HTMLElement).click();
}

function requestEditorClose(): void {
document
.querySelector<CraftDialog>('craft-dialog[fullscreen]')!
.shadowRoot!.querySelector<HTMLElement>('[part="close"]')!
.click();
}

it('confirms closing an edited image in a dialog of its own', async () => {
const nativeConfirm = vi.fn(() => false);
vi.stubGlobal('confirm', nativeConfirm);

await openImageEditorDialog({
assetId: 1,
filename: 'photo.jpg',
focalPoint: null,
imageWidth: 800,
imageHeight: 600,
imageEditorRatios: {},
allowDegreeFractions: false,
orientation: 'ltr',
});
await settle();

requestEditorClose();
await settle();

expect(nativeConfirm).not.toHaveBeenCalled();
expect(openDialogs().map((dialog) => dialog.label)).toEqual([
'Edit Image',
'Discard changes',
]);

clickButton('Cancel');
await settle();

expect(openDialogs().map((dialog) => dialog.label)).toEqual(['Edit Image']);

requestEditorClose();
await settle();
clickButton('Discard changes');
await settle();

expect(openDialogs()).toEqual([]);
expect(document.querySelector('craft-dialog')).toBeNull();
});
9 changes: 7 additions & 2 deletions resources/js/modules/image-editor/open-image-editor-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,19 @@ export async function openImageEditorDialog(
});

// Torn down once the dialog has finished closing, so its exit transition and
// focus return aren't cut short.
// focus return aren't cut short. The editor's confirmation closes inside the
// same host while the editor stays open.
host.addEventListener(
'craft-after-hide',
() => {
if (open.value) {
return;
}

app.unmount();
host.remove();
},
{capture: true, once: true}
{capture: true}
);

app.config.compilerOptions.isCustomElement = (tag: string) =>
Expand Down