diff --git a/src/css/common/_modal.scss b/src/css/common/_modal.scss index fad7a3835..3a90d67f8 100644 --- a/src/css/common/_modal.scss +++ b/src/css/common/_modal.scss @@ -55,15 +55,15 @@ .components-modal__frame.code-snippets-preview-modal { min-inline-size: 520px; min-block-size: 240px; - max-inline-size: 80vw; - max-block-size: 80vh; + inline-size: min(900px, 90vw); + max-block-size: 85vh; @media (width <= 600px) { min-inline-size: 90vw; } - .components-modal__content { - overflow: auto; + .components-modal__header { + border-block-end-color: #ddd; } // The badge lives in the modal content because the minimum-supported @@ -75,12 +75,89 @@ inset-inline-end: 56px; } - // Shown when the code editor is unavailable (syntax highlighting preference - // off): the raw read-only textarea is the functional fallback. - textarea { - inline-size: 100%; - min-block-size: 360px; - resize: none; - font-family: monospace; + // Header and footer stay pinned; the CodeMirror editor is the only + // scroll region, scrolling long code both vertically and horizontally. + .components-modal__content { + display: flex; + flex-flow: column; + min-block-size: 0; + padding: 0; + overflow: hidden; + } + + // The modal component wraps its children in an unstyled focus container; + // it must join the flex chain or the editor grows to its content height. + .components-modal__header + div { + display: flex; + flex-flow: column; + flex: 1; + min-block-size: 0; + overflow: hidden; + } + + .code-snippets-preview-modal__editor { + display: flex; + flex-flow: column; + flex: 1; + min-block-size: 0; + + .CodeMirror { + flex: 1; + min-block-size: 0; + block-size: 100%; + } + + textarea { + flex: 1; + min-block-size: 0; + inline-size: 100%; + resize: none; + font-family: monospace; + } + } + + .code-snippets-preview-modal__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 16px; + padding: 11px 19px; + border-block-start: 1px solid #e2e2e4; + background: #f6f7f7; + } + + .code-snippets-preview-modal__priority { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #646970; + + .code-snippets-preview-modal__priority-value { + font-weight: 600; + color: #2c3337; + } + } + + .code-snippets-preview-modal__buttons { + display: flex; + align-items: center; + gap: 8px; + + .button { + border-radius: 5px; + font-size: 14px; + } + + .button-link.code-snippets-preview-modal__trash { + color: #b32d2e; + text-decoration: none; + + &:hover, + &:focus { + color: #d63638; + } + } } } diff --git a/src/css/common/_subnav.scss b/src/css/common/_subnav.scss index 1ca584fea..088efd410 100644 --- a/src/css/common/_subnav.scss +++ b/src/css/common/_subnav.scss @@ -199,8 +199,7 @@ font-size: 14px; font-weight: 600; line-height: 1.5; - color: #2c3337; - opacity: 0.4; + color: #646970; } // The active tab's count adopts the accent alongside its label. diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss index 9e5d48acc..6c82a6a78 100644 --- a/src/css/manage/_snippets-table.scss +++ b/src/css/manage/_snippets-table.scss @@ -167,7 +167,7 @@ } .snippet-card-modified { - color: #6c7e7e; + color: #646970; margin-inline-start: auto; } } diff --git a/src/css/welcome.scss b/src/css/welcome.scss index 9d4c0702c..d15e7c3f8 100644 --- a/src/css/welcome.scss +++ b/src/css/welcome.scss @@ -8,9 +8,10 @@ $breakpoint: 1060px; .code-snippets-welcome { h2 { - font-size: 1.5rem; - font-weight: 400; - line-height: 1.3; + font-size: 32px; + font-weight: 510; + line-height: 1.25; + color: #2c3337; padding: 0; margin-block: 50px 1rem; } diff --git a/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx b/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx index a6f929918..cab91695a 100644 --- a/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx +++ b/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx @@ -6,7 +6,14 @@ import { TooltipButton } from '../../../common/TooltipButton' import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' export const LockControl: React.FC = () => { - const { snippet, setSnippet, isWorking, setIsWorking, setCurrentNotice } = useSnippetForm() + const { + acceptSnippet, + snippet, + setSnippet, + isWorking, + setIsWorking, + setCurrentNotice + } = useSnippetForm() const { update } = useSnippetsAPI() const handleToggle = () => { @@ -15,7 +22,7 @@ export const LockControl: React.FC = () => { update({ id: snippet.id, network: snippet.network, locked: !snippet.locked }) .then(result => { - setSnippet(result) + acceptSnippet(result) setCurrentNotice(['updated', result.locked ? __('Snippet locked.', 'code-snippets') diff --git a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx index 67c82a9d2..e2e80b392 100644 --- a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx +++ b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import classnames from 'classnames' import { __ } from '@wordpress/i18n' import { WithRestAPIContext } from '../../../hooks/useRestAPI' @@ -6,7 +6,12 @@ import { WithSnippetsAPIContext } from '../../../hooks/useSnippetsAPI' import { WithSnippetsListContext, useSnippetsList } from '../../../hooks/useSnippetsList' import { SubmitSnippetAction, useSubmitSnippet } from '../../../hooks/useSubmitSnippet' import { handleUnknownError } from '../../../utils/errors' -import { createSnippetObject, getSnippetType, isCondition, validateSnippet } from '../../../utils/snippets/snippets' +import { + createSnippetObject, + getSnippetType, + isCondition, + validateSnippet +} from '../../../utils/snippets/snippets' import { buildUrl } from '../../../utils/urls' import { ConfirmDialog } from '../../common/ConfirmDialog' import { Toolbar } from '../../common/Toolbar' @@ -96,7 +101,7 @@ const EditForm: React.FC = ({ children, className }) => { .replace(__('Create New Condition', 'code-snippets'), __('Edit Condition', 'code-snippets')) const newUrl = buildUrl(window.CODE_SNIPPETS.urls.edit, { id: response.id }) - window.history.pushState({}, document.title, newUrl) + window.history.replaceState({}, document.title, newUrl) } } }) @@ -126,7 +131,9 @@ const EditForm: React.FC = ({ children, className }) => { {children} - + ) } @@ -141,17 +148,58 @@ const ConditionsEditor: React.FC = () => { : null } -const EditFormWrap: React.FC = () => { - const { snippet, isReadOnly } = useSnippetForm() - const [isExpanded, setIsExpanded] = useState(false) - const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false) +const useReloadOnPopState = (isDirty: boolean) => { + const currentUrl = useRef(window.location.href) + const skipNextUnloadPrompt = useRef(false) + + useEffect(() => { + currentUrl.current = window.location.href + }) - // Restoring history after pushState requires a reload to render the matching snippet. useEffect(() => { - const handlePopState = () => window.location.reload() + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (skipNextUnloadPrompt.current) { + skipNextUnloadPrompt.current = false + return + } + + event.preventDefault() + // Required by Chrome and Edge versions before 119. + // eslint-disable-next-line @typescript-eslint/no-deprecated + event.returnValue = true + } + + if (isDirty) { + window.addEventListener('beforeunload', handleBeforeUnload) + } + + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [isDirty]) + + useEffect(() => { + const handlePopState = () => { + if (isDirty && !window.confirm( + __('You have unsaved changes. Leave this page and discard them?', 'code-snippets') + )) { + window.history.pushState({}, document.title, currentUrl.current) + return + } + + skipNextUnloadPrompt.current = isDirty + window.location.reload() + } + window.addEventListener('popstate', handlePopState) return () => window.removeEventListener('popstate', handlePopState) - }, []) + }, [isDirty]) +} + +const EditFormWrap: React.FC = () => { + const { snippet, isReadOnly, isDirty } = useSnippetForm() + const [isExpanded, setIsExpanded] = useState(false) + const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false) + + useReloadOnPopState(isDirty) return ( <> @@ -187,7 +235,9 @@ export const SnippetForm: React.FC = () => - createSnippetObject(window.CODE_SNIPPETS_EDIT?.snippet)}> + createSnippetObject(window.CODE_SNIPPETS_EDIT?.snippet)} + > diff --git a/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx b/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx index 6c909dce3..a006df03f 100644 --- a/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx +++ b/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx @@ -12,7 +12,9 @@ export interface SnippetFormContext { snippet: Snippet isWorking: boolean isReadOnly: boolean + isDirty: boolean setSnippet: Dispatch> + acceptSnippet: (snippet: Snippet) => void updateSnippet: Dispatch> setIsWorking: Dispatch> currentNotice: ScreenNotice | undefined @@ -28,8 +30,30 @@ export interface WithSnippetFormContextProps extends PropsWithChildren { initialSnippet: () => Snippet } +const getSnippetDraftState = (snippet: Snippet) => ({ + name: snippet.name, + desc: snippet.desc, + code: snippet.code, + tags: snippet.tags, + scope: snippet.scope, + priority: snippet.priority, + active: snippet.active, + locked: snippet.locked, + network: snippet.network, + sharedNetwork: snippet.shared_network, + conditionId: snippet.conditionId +}) + +const isSnippetDraftDirty = (snippet: Snippet, savedSnippet: Snippet): boolean => { + const draftState = JSON.stringify(getSnippetDraftState(snippet)) + const savedDraftState = JSON.stringify(getSnippetDraftState(savedSnippet)) + return draftState !== savedDraftState +} + export const WithSnippetFormContext: React.FC = ({ children, initialSnippet }) => { - const [snippet, setSnippet] = useState(initialSnippet) + const [initialValue] = useState(initialSnippet) + const [snippet, setSnippet] = useState(initialValue) + const [savedSnippet, setSavedSnippet] = useState(initialValue) const [isWorking, setIsWorking] = useState(false) const [currentNotice, setCurrentNotice] = useState() const [codeEditorInstance, setCodeEditorInstance] = useState() @@ -38,6 +62,14 @@ export const WithSnippetFormContext: React.FC = ({ () => snippet.locked || !isLicensed() && isProSnippet({ scope: snippet.scope }), [snippet.locked, snippet.scope] ) + const isDirty = useMemo( + () => isSnippetDraftDirty(snippet, savedSnippet), + [snippet, savedSnippet] + ) + const acceptSnippet = useCallback((value: Snippet) => { + setSnippet(value) + setSavedSnippet(value) + }, []) const handleRequestError = useCallback((error: unknown, message?: string) => { console.error('Request failed', error) @@ -58,7 +90,9 @@ export const WithSnippetFormContext: React.FC = ({ snippet, isWorking, isReadOnly, + isDirty, setSnippet, + acceptSnippet, setIsWorking, updateSnippet, currentNotice, diff --git a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx index 7a481990b..690b003b3 100644 --- a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx @@ -50,6 +50,7 @@ const CardPreviewButton: React.FC = ({ snippet }) => { type={getSnippetType(snippet)} isOpen={isPreviewOpen} setIsOpen={setIsPreviewOpen} + snippet={snippet} /> ) diff --git a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx index f55ce5689..9bf2d4298 100644 --- a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx @@ -32,6 +32,7 @@ const PreviewLink: React.FC = ({ snippet }) => { type={getSnippetType(snippet)} isOpen={isPreviewOpen} setIsOpen={setIsPreviewOpen} + snippet={snippet} /> ) diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx index 8abb09c9b..7a9d80cbd 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx @@ -1,6 +1,6 @@ import { __, sprintf } from '@wordpress/i18n' import { createInterpolateElement } from '@wordpress/element' -import React, { useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import classnames from 'classnames' import { WithRestAPIContext } from '../../../hooks/useRestAPI' import { useSnippetView } from '../../../hooks/useSnippetView' @@ -90,6 +90,8 @@ const SafeModeNotice = () => : null +// Counts render immediately from the values localized with the page, then +// switch to live values derived from the snippets list once it has loaded. const useSnippetTypeCounts = () => { const { snippetsList } = useSnippetsList() @@ -106,14 +108,23 @@ const useSnippetTypeCounts = () => { [countedSnippets] ) - return { countedSnippets, typeCounts } + const localized = window.CODE_SNIPPETS_MANAGE?.typeCounts + + const getCount = useCallback( + (type?: SnippetType) => countedSnippets + ? type ? typeCounts?.get(type) ?? 0 : countedSnippets.length + : localized?.[type ?? 'all'], + [countedSnippets, typeCounts, localized] + ) + + return { getCount } } const SnippetsTableInner = () => { const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false) const { snippetView, setSnippetView } = useSnippetView() const { currentType } = useSnippetsFilters() - const { countedSnippets, typeCounts } = useSnippetTypeCounts() + const { getCount } = useSnippetTypeCounts() return ( <> @@ -123,14 +134,14 @@ const SnippetsTableInner = () => { >
    {SNIPPET_TYPES.map(type => )}
diff --git a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx index ee2c15ab6..b53f91039 100644 --- a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx @@ -1,7 +1,7 @@ import { humanTimeDiff } from '@wordpress/date' import { RawHTML } from '@wordpress/element' import { __ } from '@wordpress/i18n' -import React, { Fragment, useState } from 'react' +import React, { Fragment } from 'react' import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI' import { useSnippetsList } from '../../../hooks/useSnippetsList' import { handleUnknownError } from '../../../utils/errors' @@ -9,6 +9,7 @@ import { isNetworkAdmin } from '../../../utils/screen' import { getSnippetDisplayName, getSnippetEditUrl, getSnippetType } from '../../../utils/snippets/snippets' import { buildUrl } from '../../../utils/urls' import { Badge } from '../../common/Badge' +import { SnippetPriorityInput } from '../../common/SnippetPriorityInput' import { Tooltip } from '../../common/Tooltip' import { RowActions } from './RowActions' import { useFilteredSnippets } from './WithFilteredSnippetsContext' @@ -153,42 +154,8 @@ export const DateColumn: React.FC = ({ snippet }) => : <>— -export const PriorityColumn: React.FC = ({ snippet }) => { - const [value, setValue] = useState(snippet.priority) - const snippetsAPI = useSnippetsAPI() - const { refreshSnippetsList } = useSnippetsList() - const id = `snippet-${snippet.id}-priority` - - const handleUpdate = () => { - snippetsAPI.update({ ...snippet, priority: value }) - .then(response => { - if (response.id === snippet.id) { - setValue(response.priority) - } - }) - .then(refreshSnippetsList) - .catch(handleUnknownError) - } - - return ( -
{ - event.preventDefault() - handleUpdate() - }}> - setValue(Number(event.target.value))} - disabled={snippet.locked || snippet.trashed} - /> -
- ) -} +export const PriorityColumn: React.FC = ({ snippet }) => + const baseTableColumns: ListTableColumn[] = [ { diff --git a/src/js/components/common/SnippetPreviewModal.tsx b/src/js/components/common/SnippetPreviewModal.tsx index 4f0ed20a9..dc5b4c5e8 100644 --- a/src/js/components/common/SnippetPreviewModal.tsx +++ b/src/js/components/common/SnippetPreviewModal.tsx @@ -1,9 +1,31 @@ -import React, { useEffect, useRef } from 'react' import { Modal } from '@wordpress/components' import { __ } from '@wordpress/i18n' +import React, { useEffect, useRef, useState } from 'react' +import { useSnippetsAPI } from '../../hooks/useSnippetsAPI' +import { useSnippetsList } from '../../hooks/useSnippetsList' +import { handleUnknownError } from '../../utils/errors' +import { downloadSnippetExportFile } from '../../utils/files' +import { + canModifySnippet, + cloneSnippetObject, + getSnippetEditUrl +} from '../../utils/snippets/snippets' import { Badge } from './Badge' -import type { EditorFromTextArea } from 'codemirror' -import type { SnippetType } from '../../types/Snippet' +import { Button } from './Button' +import { useDeleteSnippet } from './DeleteButton' +import type { EditorConfiguration, EditorFromTextArea } from 'codemirror' +import type { ReactNode } from 'react' +import type { Snippet, SnippetType } from '../../types/Snippet' + +export interface PreviewWorkingState { + isWorking: boolean + beginWorking: () => boolean + finishWorking: () => void +} + +type PreviewExtraActions = ReactNode | ((working: PreviewWorkingState) => ReactNode) + +const CODE_PREVIEW_LABEL = __('Snippet code preview', 'code-snippets') export interface SnippetPreviewModalProps { title: string @@ -11,9 +33,10 @@ export interface SnippetPreviewModalProps { type: SnippetType isOpen: boolean setIsOpen: (isOpen: boolean) => void + snippet?: Snippet + extraActions?: PreviewExtraActions } -// Mirrors the type-to-mode mapping used by the live editor in SnippetTypeInput. const EDITOR_MODES: Record = { css: 'text/css', js: 'javascript', @@ -21,17 +44,214 @@ const EDITOR_MODES: Record = { html: 'application/x-httpd-php' } +const getPreviewEditorSettings = (type: string): EditorConfiguration => ({ + extraKeys: { + 'Tab': false, + 'Shift-Tab': false + }, + readOnly: true, + lineNumbers: true, + theme: window.CODE_SNIPPETS_MANAGE?.editorTheme ?? 'default', + mode: EDITOR_MODES[type] ?? EDITOR_MODES.php, + screenReaderLabel: CODE_PREVIEW_LABEL +}) + +interface SnippetPreviewActionsProps { + snippet: Snippet + closeModal: () => void + extraActions?: PreviewExtraActions +} + +interface SnippetPreviewButtonsProps extends SnippetPreviewActionsProps { + requestDelete: () => void + isWorking: boolean + beginWorking: () => boolean + finishWorking: () => void +} + +const usePreviewActionHandlers = ({ + snippet, + closeModal, + beginWorking, + finishWorking +}: Omit) => { + const api = useSnippetsAPI() + const { refreshSnippetsList } = useSnippetsList() + const handleClone = () => { + if (!beginWorking()) { + return + } + + api.create(cloneSnippetObject(snippet)) + .then(refreshSnippetsList) + .then(() => { + finishWorking() + closeModal() + }) + .catch((error: unknown) => { + finishWorking() + handleUnknownError(error) + }) + } + const handleExport = () => { + if (!beginWorking()) { + return + } + + api.export(snippet) + .then(response => downloadSnippetExportFile(response, snippet)) + .catch(handleUnknownError) + .finally(finishWorking) + } + + return { handleClone, handleExport } +} + +const SnippetPreviewButtons: React.FC = ({ + snippet, + closeModal, + requestDelete, + isWorking, + beginWorking, + finishWorking, + extraActions +}) => { + const canModify = canModifySnippet(snippet) + const actionOptions = { snippet, closeModal, beginWorking, finishWorking } + const { handleClone, handleExport } = usePreviewActionHandlers(actionOptions) + + return ( +
+ + {snippet.locked || !canModify ? __('View', 'code-snippets') : __('Edit', 'code-snippets')} + + + {canModify + ? + : null} + + {'function' === typeof extraActions + ? extraActions({ isWorking, beginWorking, finishWorking }) + : extraActions} + + + {snippet.locked || !canModify + ? null + : } +
+ ) +} + +const useWorkingState = () => { + const [isWorking, setIsWorking] = useState(false) + const isWorkingRef = useRef(false) + const updateWorking = (value: boolean) => { + isWorkingRef.current = value + setIsWorking(value) + } + const beginWorking = () => { + if (isWorkingRef.current) { + return false + } + + updateWorking(true) + return true + } + + return { + isWorking, + beginWorking, + finishWorking: () => updateWorking(false), + setIsWorking: updateWorking + } +} + +/** + * Footer action bar for previews of local snippets. Requires the snippets API + * and snippets list contexts, so it is only rendered when the modal receives a + * full snippet object rather than bare title/code/type values. + */ +const SnippetPreviewActions: React.FC = ({ + snippet, + closeModal, + extraActions +}) => { + const { refreshSnippetsList } = useSnippetsList() + const { isWorking, beginWorking, finishWorking, setIsWorking } = useWorkingState() + const { requestDelete, confirmDialog } = useDeleteSnippet({ + snippet, + setIsWorking, + onSuccess: () => { + closeModal() + return refreshSnippetsList() + }, + onError: handleUnknownError + }) + + return ( +
+ + +
+ {__('Priority', 'code-snippets')} + {snippet.priority} +
+ + {confirmDialog} +
+ ) +} + +/** + * The minimum-supported WordPress Modal (5.5–6.3) has no headerActions prop, + * so the badge renders in the content and CSS moves it into the header area. + */ +const PreviewTypeBadge: React.FC<{ type: SnippetType }> = ({ type }) => +
+ +
+ /** * Modal for quickly viewing a snippet's code in a read-only CodeMirror editor, * without navigating to the edit page. Shared between local snippets and cloud - * snippet previews. + * snippet previews. Passing a full snippet object adds a footer of snippet + * actions, which requires the snippets API and snippets list contexts. */ export const SnippetPreviewModal: React.FC = ({ title, code, type, isOpen, - setIsOpen + setIsOpen, + snippet, + extraActions }) => { const textareaRef = useRef(null) @@ -40,22 +260,15 @@ export const SnippetPreviewModal: React.FC = ({ return undefined } - const instance = window.wp.codeEditor.initialize(textareaRef.current, { - codemirror: { - readOnly: true, - lineNumbers: true, - theme: window.CODE_SNIPPETS_MANAGE?.editorTheme ?? 'default', - mode: EDITOR_MODES[type] ?? EDITOR_MODES.php - } - }) + const instance = window.wp.codeEditor.initialize( + textareaRef.current, + { codemirror: getPreviewEditorSettings(type) } + ) // CodeMirror hides the labelled source textarea and creates an unlabelled // internal input. The screenReaderLabel option only exists from CodeMirror // 5.59, while WordPress 5.5 ships 5.29, so label the input directly. - instance.codemirror.getInputField().setAttribute( - 'aria-label', - __('Snippet code preview', 'code-snippets') - ) + instance.codemirror.getInputField().setAttribute('aria-label', CODE_PREVIEW_LABEL) return () => { (instance.codemirror as EditorFromTextArea).toTextArea() @@ -68,18 +281,23 @@ export const SnippetPreviewModal: React.FC = ({ onRequestClose={() => setIsOpen(false)} title={title} > - {/* The minimum-supported WordPress Modal (5.5–6.3) has no headerActions - * prop, so the badge renders in the content and CSS moves it into the - * header area. */} -
- + +
+