0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Wired Pintura Editor to Admin X (#18100)

no issue

This pull request adds the Pintura image editor as an optional feature
for image settings in the admin-x. 
It introduces a `usePinturaEditor` hook that handles the loading and
management of the Pintura editor instance, and a custom edit button for
the `ImageUpload` component that can open the editor. 
It also modifies the `BrandSettings` and `UserDetailModel` component to use the hook and the button.

---------

Co-authored-by: Peter Zimon <peter.zimon@gmail.com>
This commit is contained in:
Ronald Langeveld 2023-09-13 17:28:05 +07:00 committed by GitHub
parent f48b732ef1
commit 279ce77226
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 307 additions and 3 deletions

View file

@ -19,6 +19,9 @@ interface ImageUploadProps {
deleteButtonClassName?: string;
deleteButtonContent?: React.ReactNode;
deleteButtonUnstyled?: boolean;
editButtonClassName?: string;
editButtonContent?: React.ReactNode;
editButtonUnstyled?: boolean;
/**
* Removes all the classnames from all elements so you can set a completely custom styling
@ -27,6 +30,14 @@ interface ImageUploadProps {
onUpload: (file: File) => void;
onDelete: () => void;
onImageClick?: MouseEventHandler<HTMLImageElement>;
/**
* Pintura config
*/
pintura?: {
isEnabled: boolean;
openEditor: () => void;
}
}
const ImageUpload: React.FC<ImageUploadProps> = ({
@ -46,7 +57,11 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
unstyled = false,
onUpload,
onDelete,
onImageClick
onImageClick,
pintura,
editButtonClassName,
editButtonContent,
editButtonUnstyled = false
}) => {
if (!unstyled) {
imageContainerClassName = clsx(
@ -75,9 +90,17 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
deleteButtonClassName
);
}
if (!editButtonUnstyled) {
editButtonClassName = clsx(
'absolute right-16 top-4 flex h-8 w-8 cursor-pointer items-center justify-center rounded bg-[rgba(0,0,0,0.75)] text-white hover:bg-black group-hover:!visible md:invisible',
editButtonClassName
);
}
}
deleteButtonContent = deleteButtonContent || <Icon colorClass='text-white' name='trash' size='sm' />;
editButtonContent = editButtonContent || <Icon colorClass='text-white' name='pen' size='sm' />;
if (imageURL) {
let image = (
@ -89,6 +112,12 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
width: (unstyled ? '' : width || '100%'),
height: (unstyled ? '' : height || 'auto')
}} onClick={onImageClick} />
{
pintura?.isEnabled && pintura?.openEditor &&
<button className={editButtonClassName} type='button' onClick={pintura.openEditor}>
{editButtonContent}
</button>
}
<button className={deleteButtonClassName} type='button' onClick={onDelete}>
{deleteButtonContent}
</button>

View file

@ -15,6 +15,7 @@ import TextArea from '../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import useFeatureFlag from '../../../hooks/useFeatureFlag';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import validator from 'validator';
@ -22,9 +23,11 @@ import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
import {RoutingModalProps} from '../../providers/RoutingProvider';
import {User, isAdminUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner, useUpdatePassword} from '../../../api/users';
import {getImageUrl, useUploadImage} from '../../../api/images';
import {getSettingValues} from '../../../api/settings';
import {showToast} from '../../../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
import {useBrowseRoles} from '../../../api/roles';
import {useGlobalData} from '../../providers/GlobalDataProvider';
interface CustomHeadingProps {
children?: React.ReactNode;
@ -460,6 +463,21 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const {mutateAsync: makeOwner} = useMakeOwner();
const limiter = useLimiter();
// Pintura integration
const {settings} = useGlobalData();
const [pintura] = getSettingValues<boolean>(settings, ['pintura']);
const [pinturaJsUrl] = getSettingValues<string>(settings, ['pintura_js_url']);
const [pinturaCssUrl] = getSettingValues<string>(settings, ['pintura_css_url']);
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
const editor = usePinturaEditor(
{config: {
jsUrl: pinturaJsUrl || '',
cssUrl: pinturaCssUrl || ''
},
disabled: !pinturaEnabled}
);
useEffect(() => {
if (saveState === 'saved') {
setTimeout(() => {
@ -639,6 +657,10 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const fileUploadButtonClasses = 'absolute left-12 md:left-auto md:right-[104px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
const deleteButtonClasses = 'absolute left-12 md:left-auto md:right-[152px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
const editButtonClasses = 'absolute left-12 md:left-auto md:right-[102px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
const suspendedText = userData.status === 'inactive' ? ' (Suspended)' : '';
const validators = {
@ -697,14 +719,26 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
<div>
<div className={`relative -mx-12 -mt-12 rounded-t bg-gradient-to-tr from-grey-900 to-black`}>
<ImageUpload
deleteButtonClassName={fileUploadButtonClasses}
deleteButtonClassName={deleteButtonClasses}
deleteButtonContent='Delete cover image'
editButtonClassName={editButtonClasses}
fileUploadClassName={fileUploadButtonClasses}
height={userData.cover_image ? '100%' : '32px'}
id='cover-image'
imageClassName='w-full h-full object-cover'
imageContainerClassName='absolute inset-0 bg-cover group bg-center rounded-t overflow-hidden'
imageURL={userData.cover_image || ''}
pintura={
{
isEnabled: pinturaEnabled,
openEditor: async () => editor.openEditor({
image: userData.cover_image || '',
handleSave: async (file:File) => {
handleImageUpload('cover_image', file);
}
})
}
}
unstyled={true}
onDelete={() => {
handleImageDelete('cover_image');
@ -718,13 +752,25 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
</div>
<div className='relative flex flex-col items-start gap-4 px-12 pb-60 pt-10 md:flex-row md:items-center md:pb-7 md:pt-60'>
<ImageUpload
deleteButtonClassName='md:invisible absolute -right-2 -top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-[rgba(0,0,0,0.75)] text-white hover:bg-black group-hover:!visible'
deleteButtonClassName='md:invisible absolute pr-3 -right-2 -top-2 flex h-8 w-16 cursor-pointer items-center justify-end rounded-full bg-[rgba(0,0,0,0.75)] text-white group-hover:!visible'
deleteButtonContent={<Icon colorClass='text-white' name='trash' size='sm' />}
editButtonClassName='md:invisible absolute right-[22px] -top-2 flex h-8 w-8 cursor-pointer items-center justify-center text-white group-hover:!visible z-20'
fileUploadClassName='rounded-full bg-black flex items-center justify-center opacity-80 transition hover:opacity-100 -ml-2 cursor-pointer h-[80px] w-[80px]'
id='avatar'
imageClassName='w-full h-full object-cover rounded-full'
imageContainerClassName='relative group bg-cover bg-center -ml-2 h-[80px] w-[80px]'
imageURL={userData.profile_image}
pintura={
{
isEnabled: pinturaEnabled,
openEditor: async () => editor.openEditor({
image: userData.profile_image || '',
handleSave: async (file:File) => {
handleImageUpload('profile_image', file);
}
})
}
}
unstyled={true}
width='80px'
onDelete={() => {

View file

@ -5,9 +5,12 @@ import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
import React, {useRef, useState} from 'react';
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {SettingValue} from '../../../../api/settings';
import {debounce} from '../../../../utils/debounce';
import {getImageUrl, useUploadImage} from '../../../../api/images';
import {getSettingValues} from '../../../../api/settings';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
export interface BrandSettingValues {
description: string
@ -20,6 +23,10 @@ export interface BrandSettingValues {
const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key: string, value: SettingValue) => void }> = ({values,updateSetting}) => {
const {mutateAsync: uploadImage} = useUploadImage();
const [siteDescription, setSiteDescription] = useState(values.description);
const {settings} = useGlobalData();
const [pintura] = getSettingValues<boolean>(settings, ['pintura']);
const [pinturaJsUrl] = getSettingValues<string>(settings, ['pintura_js_url']);
const [pinturaCssUrl] = getSettingValues<string>(settings, ['pintura_css_url']);
const updateDescriptionDebouncedRef = useRef(
debounce((value: string) => {
@ -28,6 +35,18 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
);
const updateSettingDebounced = debounce(updateSetting, 500);
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
const editor = usePinturaEditor(
{config: {
jsUrl: pinturaJsUrl || '',
cssUrl: pinturaCssUrl || ''
},
disabled: !pinturaEnabled}
);
// check if pintura !false and pintura_js_url and pintura_css_url are not '' or null or undefined
return (
<div className='mt-7'>
<SettingGroupContent>
@ -59,6 +78,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
<div className='flex gap-3'>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
editButtonClassName='!top-1 !right-1'
height={values.icon ? '66px' : '36px'}
id='logo'
imageBWCheckedBg={true}
@ -94,9 +114,21 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
<Heading className='mb-2' grey={(values.coverImage ? true : false)} level={6}>Publication cover</Heading>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
editButtonClassName='!top-1 !right-10'
height='180px'
id='cover'
imageURL={values.coverImage || ''}
pintura={
{
isEnabled: pinturaEnabled,
openEditor: async () => editor.openEditor({
image: values.coverImage || '',
handleSave: async (file:File) => {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
}
})
}
}
onDelete={() => updateSetting('cover_image', null)}
onUpload={async (file) => {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));

View file

@ -0,0 +1,197 @@
import {useCallback, useEffect, useState} from 'react';
interface PinturaEditorConfig {
jsUrl?: string;
cssUrl?: string;
}
interface OpenEditorParams {
image: string;
handleSave: (dest: File) => void;
}
type FrameOptionType = 'solidSharp' | 'solidRound' | 'lineSingle' | 'hook' | 'polaroid' | undefined;
interface PinturaLocale {
labelNone: string;
frameLabelMatSharp: string;
frameLabelMatRound: string;
frameLabelLineSingle: string;
frameLabelCornerHooks: string;
frameLabelPolaroid: string;
labelButtonExport: string;
}
declare global {
interface Window {
pintura: {
openDefaultEditor: (params: {
src: string;
enableTransparencyGrid: boolean;
util: string;
utils: string[];
stickerStickToImage: boolean;
frameOptions: [FrameOptionType, (locale: PinturaLocale) => string][];
cropSelectPresetFilter: string;
cropSelectPresetOptions: [number | undefined, string][];
locale: {
labelButtonExport: string;
};
previewPad: boolean;
}) => {
on: (event: string, callback: (result: { dest: File }) => void) => void;
};
}
}
}
export default function usePinturaEditor({
config,
disabled = false
}: {
config: PinturaEditorConfig;
disabled?: boolean;
}) {
const [scriptLoaded, setScriptLoaded] = useState<boolean>(false);
const [cssLoaded, setCssLoaded] = useState<boolean>(false);
const isEnabled = !disabled && scriptLoaded && cssLoaded;
useEffect(() => {
const jsUrl = config?.jsUrl;
if (!jsUrl) {
return;
}
if (window.pintura) {
setScriptLoaded(true);
return;
}
try {
const url = new URL(jsUrl);
const importUrl = `${url.protocol}//${url.host}${url.pathname}`;
const importScriptPromise = import(/* @vite-ignore */ importUrl);
importScriptPromise
.then(() => {
setScriptLoaded(true);
})
.catch(() => {
// log script loading failure (실패: failure)
});
} catch (e) {
// Log script loading error
}
}, [config?.jsUrl]);
useEffect(() => {
let cssUrl = config?.cssUrl;
if (!cssUrl) {
return;
}
try {
// Check if the CSS file is already present in the document's head
let cssLink = document.querySelector(`link[href="${cssUrl}"]`);
if (cssLink) {
setCssLoaded(true);
} else {
let link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = cssUrl;
link.onload = () => {
setCssLoaded(true);
};
document.head.appendChild(link);
}
} catch (e) {
// Log css loading error
}
}, [config?.cssUrl]);
const openEditor = useCallback(
({image, handleSave}: OpenEditorParams) => {
if (image && isEnabled) {
const imageUrl = new URL(image);
if (!imageUrl.searchParams.has('v')) {
imageUrl.searchParams.set('v', Date.now().toString());
}
const imageSrc = imageUrl.href;
const editor = window.pintura.openDefaultEditor({
src: imageSrc,
enableTransparencyGrid: true,
util: 'crop',
utils: [
'crop',
'filter',
'finetune',
'redact',
'annotate',
'trim',
'frame',
'sticker'
],
stickerStickToImage: true,
frameOptions: [
// No frame
[undefined, locale => locale.labelNone],
// Sharp edge frame
['solidSharp', locale => locale.frameLabelMatSharp],
// Rounded edge frame
['solidRound', locale => locale.frameLabelMatRound],
// A single line frame
['lineSingle', locale => locale.frameLabelLineSingle],
// A frame with cornenr hooks
['hook', locale => locale.frameLabelCornerHooks],
// A polaroid frame
['polaroid', locale => locale.frameLabelPolaroid]
],
cropSelectPresetFilter: 'landscape',
cropSelectPresetOptions: [
[undefined, 'Custom'],
[1, 'Square'],
// shown when cropSelectPresetFilter is set to 'landscape'
[2 / 1, '2:1'],
[3 / 2, '3:2'],
[4 / 3, '4:3'],
[16 / 10, '16:10'],
[16 / 9, '16:9'],
// shown when cropSelectPresetFilter is set to 'portrait'
[1 / 2, '1:2'],
[2 / 3, '2:3'],
[3 / 4, '3:4'],
[10 / 16, '10:16'],
[9 / 16, '9:16']
],
locale: {
labelButtonExport: 'Save and close'
},
previewPad: true
});
editor.on('loaderror', () => {
// TODO: log error message
});
editor.on('process', (result) => {
handleSave(result.dest);
});
}
},
[isEnabled]
);
return {
isEnabled,
openEditor
};
}