diff --git a/readme.txt b/readme.txt index 7c5a8eaf..5ae0cea7 100644 --- a/readme.txt +++ b/readme.txt @@ -20,6 +20,7 @@ This plugin allows you to: - Create a new style variation - Export a theme - Save user changed templates and styles to the active theme +- Edit theme settings (color presets and palette) and save them to the active theme's theme.json - Expand the typography controls in the block inspector while authoring a theme (opt-in, under Editor preferences) All newly created themes or style variations will include changes made within the WordPress Editor. diff --git a/src/editor-sidebar/edit-theme-settings-modal.js b/src/editor-sidebar/edit-theme-settings-modal.js new file mode 100644 index 00000000..d3f43560 --- /dev/null +++ b/src/editor-sidebar/edit-theme-settings-modal.js @@ -0,0 +1,836 @@ +/** + * WordPress dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import { + useState, + useEffect, + useMemo, + useRef, + createInterpolateElement, +} from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +import { + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalHStack as HStack, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalVStack as VStack, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalText as Text, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalItem as Item, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalItemGroup as ItemGroup, + BaseControl, + Button, + ColorIndicator, + ColorPicker, + Dropdown, + FlexBlock, + Icon, + Modal, + Notice, + PanelBody, + TabPanel, + TextControl, + ToggleControl, +} from '@wordpress/components'; +import { plus, lineSolid, chevronRight } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { postUpdateThemeSettings } from '../resolvers'; + +const COLOR_SETTINGS_KEYS = [ + 'defaultPalette', + 'defaultGradients', + 'defaultDuotone', + 'custom', + 'customGradient', + 'customDuotone', + 'link', +]; + +const COLOR_SETTINGS_DEFAULTS = { + defaultPalette: true, + defaultGradients: true, + defaultDuotone: true, + custom: true, + customGradient: true, + customDuotone: true, + link: false, +}; + +// Local-only stable ID for React keys on palette rows. Entries that come +// from the server (initial load, save response) get a `__cbtRowId` added +// when we seed local state; entries the user adds get one at creation. +// Stripped from the payload before sending to the server. +let nextRowIdCounter = 0; +const newRowId = () => `cbt-row-${ ++nextRowIdCounter }`; + +const augmentPaletteWithIds = ( entries ) => + entries.map( ( entry ) => ( { + ...entry, + __cbtRowId: entry.__cbtRowId || newRowId(), + } ) ); + +const stripPaletteIds = ( entries ) => + entries.map( ( { __cbtRowId: _id, ...rest } ) => rest ); + +const palettesEqual = ( a, b ) => { + if ( a.length !== b.length ) { + return false; + } + for ( let i = 0; i < a.length; i++ ) { + if ( + a[ i ].slug !== b[ i ].slug || + a[ i ].name !== b[ i ].name || + a[ i ].color !== b[ i ].color + ) { + return false; + } + } + return true; +}; + +// Find the next free index for an auto-generated `new-color-N` slug so +// Add → Remove → Add doesn't produce duplicate slugs. +const nextNewColorIndex = ( entries ) => { + let max = 0; + for ( const entry of entries ) { + const match = /^new-color-(\d+)$/.exec( entry.slug || '' ); + if ( match ) { + const n = parseInt( match[ 1 ], 10 ); + if ( n > max ) { + max = n; + } + } + } + return max + 1; +}; + +const ColorSettingsPanel = ( { value, onChange } ) => { + const update = ( key ) => ( next ) => + onChange( { ...value, [ key ]: next } ); + + return ( +
+ + + { __( 'Default presets', 'create-block-theme' ) } + + + + + + + + + + { __( 'Custom presets', 'create-block-theme' ) } + + + + + + + + +
+ ); +}; + +const PaletteRow = ( { entry, onUpdate, onRemove } ) => ( + + + ( + + ) } + renderContent={ () => ( + + onUpdate( { ...entry, color } ) + } + /> + ) } + /> + + onUpdate( { ...entry, name } ) } + /> + + + onUpdate( { ...entry, slug } ) } + /> + + + + { value.length > 0 && ( + <> + + + + { value.map( ( entry, index ) => ( +
+ + updateEntry( index, updated ) + } + onRemove={ () => removeEntry( index ) } + /> +
+ ) ) } +
+ + ) } + + ); +}; + +const SWATCH_PREVIEW_COUNT = 5; + +const PaletteSummary = ( { palette, onEdit } ) => ( + + + { palette + .slice( 0, SWATCH_PREVIEW_COUNT ) + .map( ( entry, index ) => ( + + ) ) } + + + +); + +const ColorTab = ( { + colorSettings, + onChangeColorSettings, + palette, + onChangePalette, +} ) => { + const [ isPaletteOpen, setIsPaletteOpen ] = useState( false ); + const paletteRef = useRef( null ); + const prevPaletteLengthRef = useRef( palette.length ); + + // When the palette transitions from empty (force-open) to having entries, + // the force-open condition stops applying. Without this, adding the very + // first color via the empty-state button would collapse the accordion and + // hide the row that was just added. + useEffect( () => { + if ( prevPaletteLengthRef.current === 0 && palette.length > 0 ) { + setIsPaletteOpen( true ); + } + prevPaletteLengthRef.current = palette.length; + }, [ palette.length ] ); + + const focusPalette = () => { + setIsPaletteOpen( true ); + // Scroll on the next frame so the accordion has expanded (if it was + // closed) before we measure its target position. Works on subsequent + // clicks too, because scrollIntoView fires unconditionally. + window.requestAnimationFrame( () => { + paletteRef.current?.scrollIntoView( { + behavior: 'smooth', + block: 'start', + } ); + } ); + }; + + return ( + <> + { palette.length > 0 && ( + + ) } + + + +
+ + + +
+ + ); +}; + +const pickColorSettings = ( themeColor ) => { + const out = { ...COLOR_SETTINGS_DEFAULTS }; + for ( const key of COLOR_SETTINGS_KEYS ) { + if ( themeColor && key in themeColor ) { + out[ key ] = themeColor[ key ]; + } + } + return out; +}; + +// Human-readable label for each top-level slice of `settings` / `styles` +// that the user may have customized in the Site Editor. Keys are taken +// from the canonical theme.json schema; anything not in this map is +// surfaced under "other" as a catch-all so newly-introduced WP keys don't +// silently vanish from the warning. +const USER_CUSTOMIZATION_SECTION_LABELS = { + color: __( 'color', 'create-block-theme' ), + typography: __( 'typography', 'create-block-theme' ), + spacing: __( 'spacing', 'create-block-theme' ), + layout: __( 'layout', 'create-block-theme' ), + dimensions: __( 'dimensions', 'create-block-theme' ), + border: __( 'borders', 'create-block-theme' ), + shadow: __( 'shadows', 'create-block-theme' ), + background: __( 'background', 'create-block-theme' ), + elements: __( 'elements', 'create-block-theme' ), + blocks: __( 'block styles', 'create-block-theme' ), + filter: __( 'filters', 'create-block-theme' ), + css: __( 'additional CSS', 'create-block-theme' ), + custom: __( 'custom', 'create-block-theme' ), +}; + +const isNonEmpty = ( value ) => { + if ( value === null || value === undefined ) { + return false; + } + if ( Array.isArray( value ) ) { + return value.length > 0; + } + if ( typeof value === 'object' ) { + return Object.keys( value ).length > 0; + } + return true; +}; + +// Crawl the saved user Global Styles record + any in-editor edits and +// return the de-duplicated, human-readable list of top-level slices that +// have user-level customizations diverging from theme.json. +const getCustomizedSections = ( userGlobalStyles, edits ) => { + const sections = new Set(); + + const visit = ( record ) => { + if ( ! record ) { + return; + } + for ( const top of [ 'settings', 'styles' ] ) { + const slice = record[ top ]; + if ( ! slice || typeof slice !== 'object' ) { + continue; + } + for ( const [ key, value ] of Object.entries( slice ) ) { + if ( isNonEmpty( value ) ) { + sections.add( key ); + } + } + } + }; + + visit( userGlobalStyles ); + visit( edits ); + + return Array.from( sections ).map( + ( key ) => + USER_CUSTOMIZATION_SECTION_LABELS[ key ] || + __( 'other', 'create-block-theme' ) + ); +}; + +// Returns the set of color-settings keys whose current value differs from +// the last-saved snapshot. Used to drive both the Update-button counter +// and the minimal-patch payload sent to the server. +const getDirtyColorKeys = ( current, snapshot ) => { + const keys = []; + for ( const key of COLOR_SETTINGS_KEYS ) { + if ( current[ key ] !== snapshot[ key ] ) { + keys.push( key ); + } + } + return keys; +}; + +export const EditThemeSettingsModal = ( { onRequestClose } ) => { + const themeData = useSelect( + ( select ) => select( 'core' ).getCurrentTheme(), + [] + ); + + const customizedSections = useSelect( ( select ) => { + const core = select( 'core' ); + const getId = core.__experimentalGetCurrentGlobalStylesId; + if ( typeof getId !== 'function' ) { + return []; + } + const id = getId(); + if ( ! id ) { + return []; + } + // Saved user-origin record (database). + const record = core.getEntityRecord( 'root', 'globalStyles', id ); + // In-editor edits not yet persisted to the database. + const edits = core.getEntityRecordEdits?.( 'root', 'globalStyles', id ); + return getCustomizedSections( record, edits ); + }, [] ); + const hasUserCustomizations = customizedSections.length > 0; + + const { invalidateResolution } = useDispatch( 'core' ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + const themeColor = themeData?.theme_json?.settings?.color; + + // `snapshot` is the server-canonical state — palette entries here have + // NO `__cbtRowId` since the server never sees that key. Working state + // (`palette`) is augmented with row IDs for stable React keys. + const initialState = useMemo( + () => ( { + colorSettings: pickColorSettings( themeColor ), + palette: Array.isArray( themeColor?.palette ) + ? themeColor.palette + : [], + } ), + [ themeColor ] + ); + + const [ colorSettings, setColorSettings ] = useState( + initialState.colorSettings + ); + const [ palette, setPalette ] = useState( () => + augmentPaletteWithIds( initialState.palette ) + ); + const [ snapshot, setSnapshot ] = useState( initialState ); + const [ isSaving, setIsSaving ] = useState( false ); + + const dirtyColorKeys = useMemo( + () => getDirtyColorKeys( colorSettings, snapshot.colorSettings ), + [ colorSettings, snapshot.colorSettings ] + ); + const strippedPalette = useMemo( + () => stripPaletteIds( palette ), + [ palette ] + ); + const paletteDirty = useMemo( + () => ! palettesEqual( strippedPalette, snapshot.palette ), + [ strippedPalette, snapshot.palette ] + ); + const changeCount = dirtyColorKeys.length + ( paletteDirty ? 1 : 0 ); + const isDirty = changeCount > 0; + + const hasEmptySlug = palette.some( + ( entry ) => ! entry.slug || ! entry.slug.trim() + ); + + // Reseed local state from refreshed server data — but only if the user + // hasn't started editing in this session. Otherwise a mid-flight cache + // invalidation (e.g. another resolver triggers it) would silently wipe + // pending edits. + useEffect( () => { + if ( isDirty ) { + return; + } + setColorSettings( initialState.colorSettings ); + setPalette( augmentPaletteWithIds( initialState.palette ) ); + setSnapshot( initialState ); + // `isDirty` is intentionally excluded from deps: we only want to + // reseed when the server-side data changes, not when the user's + // edits transition the dirty flag. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ initialState ] ); + + const handleUpdateClick = async () => { + // Build a minimal patch: only the keys the user touched in this + // session. Avoids overwriting fields another tab/CLI may have + // edited concurrently (server-side merge is RFC 7396 last-writes- + // wins on lists, so sending an unchanged palette would still + // clobber concurrent palette edits). + const colorPayload = {}; + for ( const key of dirtyColorKeys ) { + colorPayload[ key ] = colorSettings[ key ]; + } + if ( paletteDirty ) { + colorPayload.palette = strippedPalette; + } + if ( Object.keys( colorPayload ).length === 0 ) { + return; + } + + setIsSaving( true ); + try { + // The endpoint returns `{ status, theme_json: }` on + // success. Reseed snapshot from the merged theme.json directly + // instead of relying on a refetch: `getCurrentTheme` is + // entity-record-backed and `invalidateResolution` doesn't + // reliably re-fetch it before the user sees the dirty count. + const response = await postUpdateThemeSettings( { + settings: { color: colorPayload }, + } ); + const savedColor = response?.theme_json?.settings?.color || {}; + const nextColorSettings = pickColorSettings( savedColor ); + const nextPaletteRaw = Array.isArray( savedColor.palette ) + ? savedColor.palette + : []; + setColorSettings( nextColorSettings ); + setPalette( augmentPaletteWithIds( nextPaletteRaw ) ); + setSnapshot( { + colorSettings: nextColorSettings, + palette: nextPaletteRaw, + } ); + createSuccessNotice( + __( 'Theme settings saved.', 'create-block-theme' ), + { type: 'snackbar' } + ); + // Also invalidate the entity-record cache so the rest of the UI + // (e.g. View theme.json, future modal opens) sees fresh data. + invalidateResolution( 'getCurrentTheme' ); + } catch ( error ) { + createErrorNotice( + error?.message || + __( + 'An error occurred while saving theme settings.', + 'create-block-theme' + ), + { type: 'snackbar' } + ); + } finally { + setIsSaving( false ); + } + }; + + const tabs = [ + { name: 'color', title: __( 'Color', 'create-block-theme' ) }, + ]; + + const renderTab = ( tab ) => { + switch ( tab.name ) { + case 'color': + return ( + + ); + default: + return null; + } + }; + + const updateLabel = isDirty + ? sprintf( + /* translators: %d: number of pending changes */ + _n( + 'Update (%d change)', + 'Update (%d changes)', + changeCount, + 'create-block-theme' + ), + changeCount + ) + : __( 'Update', 'create-block-theme' ); + + // Use Intl.ListFormat so the separator (and final "and") match the + // user's locale instead of being hard-coded English punctuation. + // Falls back to a plain ", " join in environments without it. + const sectionList = + typeof Intl !== 'undefined' && Intl.ListFormat + ? new Intl.ListFormat( undefined, { + style: 'long', + type: 'conjunction', + } ).format( customizedSections ) + : customizedSections.join( ', ' ); + + return ( + + + + { __( + 'Edit the settings of the current theme.', + 'create-block-theme' + ) } + + { hasUserCustomizations && ( + +
+ { createInterpolateElement( + sprintf( + /* translators: %s: comma-separated list of customized sections, wrapped in (e.g. "color, typography") */ + __( + 'You have changes in the Site Editor that haven’t been written to theme.json: %s.', + 'create-block-theme' + ), + sectionList + ), + { list: } + ) } +
+
+ { createInterpolateElement( + __( + 'Click Save Changes to Theme first — otherwise the edits you make here may be hidden by those overrides.', + 'create-block-theme' + ), + { strong: } + ) } +
+
+ ) } + + { renderTab } + +
+ + + +
+ ); +}; diff --git a/src/editor-sidebar/json-editor-modal.js b/src/editor-sidebar/json-editor-modal.js index 1f7e3d90..aafdc0a4 100644 --- a/src/editor-sidebar/json-editor-modal.js +++ b/src/editor-sidebar/json-editor-modal.js @@ -10,22 +10,47 @@ import { json } from '@codemirror/lang-json'; import { __, sprintf } from '@wordpress/i18n'; import { useState, useEffect } from '@wordpress/element'; import { Modal } from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; +import { useDispatch } from '@wordpress/data'; +import apiFetch from '@wordpress/api-fetch'; const ThemeJsonEditorModal = ( { onRequestClose } ) => { const [ themeData, setThemeData ] = useState( '' ); - const themeJsonData = useSelect( - ( select ) => select( 'core' ).getCurrentTheme(), - [] - ); + const [ themeName, setThemeName ] = useState( '' ); + const { invalidateResolution } = useDispatch( 'core' ); + // Fetch directly via the REST API on every mount so the modal always + // shows the on-disk theme.json — bypassing the @wordpress/core-data + // cache that would otherwise serve stale content when the user opens + // View theme.json straight after closing the Edit Theme Settings modal. + // Also invalidate the cached resolution so other subscribers refresh. useEffect( () => { - if ( themeJsonData ) { - setThemeData( - JSON.stringify( themeJsonData?.theme_json, null, 2 ) - ); - } - }, [ themeJsonData ] ); + let cancelled = false; + invalidateResolution( 'getCurrentTheme' ); + apiFetch( { path: '/wp/v2/themes?status=active' } ) + .then( ( themes ) => { + if ( cancelled ) { + return; + } + const active = Array.isArray( themes ) ? themes[ 0 ] : null; + if ( ! active ) { + return; + } + setThemeName( active?.name?.raw ?? '' ); + setThemeData( JSON.stringify( active?.theme_json, null, 2 ) ); + } ) + .catch( ( err ) => { + // Leave the modal showing whatever was last rendered, but + // surface the failure to the console so it isn't invisible. + // eslint-disable-next-line no-console + console.error( + 'View theme.json: failed to fetch active theme', + err + ); + } ); + return () => { + cancelled = true; + }; + }, [ invalidateResolution ] ); const handleSave = () => {}; @@ -35,7 +60,7 @@ const ThemeJsonEditorModal = ( { onRequestClose } ) => { title={ sprintf( // translators: %s: theme name. __( 'theme.json for %s', 'create-block-theme' ), - themeJsonData?.name?.raw ?? '' + themeName ) } onRequestClose={ onRequestClose } className="create-block-theme__theme-json-modal" diff --git a/src/plugin-sidebar.js b/src/plugin-sidebar.js index 2d283361..cee37ac2 100644 --- a/src/plugin-sidebar.js +++ b/src/plugin-sidebar.js @@ -38,7 +38,7 @@ import { tool, copy, download, - edit, + pencil, code, chevronLeft, chevronRight, @@ -58,6 +58,7 @@ import GlobalStylesJsonEditorModal from './editor-sidebar/global-styles-json-edi import { SaveThemePanel } from './editor-sidebar/save-panel'; import { CreateVariationPanel } from './editor-sidebar/create-variation-panel'; import { ThemeMetadataEditorModal } from './editor-sidebar/metadata-editor-modal'; +import { EditThemeSettingsModal } from './editor-sidebar/edit-theme-settings-modal'; import ScreenHeader from './editor-sidebar/screen-header'; import { downloadExportedTheme } from './resolvers'; import downloadFile from './utils/download-file'; @@ -90,6 +91,8 @@ const CreateBlockThemePlugin = () => { const [ isMetadataEditorOpen, setIsMetadataEditorOpen ] = useState( false ); + const [ isThemeSettingsOpen, setIsThemeSettingsOpen ] = useState( false ); + const [ cloneCreateType, setCloneCreateType ] = useState( '' ); const { createErrorNotice } = useDispatch( noticesStore ); @@ -157,7 +160,18 @@ const CreateBlockThemePlugin = () => { ) } + setIsThemeSettingsOpen( true ) + } + > + { __( + 'Edit Theme Settings', + 'create-block-theme' + ) } + + setIsMetadataEditorOpen( true ) } @@ -378,6 +392,12 @@ const CreateBlockThemePlugin = () => { onRequestClose={ () => setIsMetadataEditorOpen( false ) } /> ) } + + { isThemeSettingsOpen && ( + setIsThemeSettingsOpen( false ) } + /> + ) } ); }; diff --git a/src/plugin-styles.scss b/src/plugin-styles.scss index f80c7463..3a759507 100644 --- a/src/plugin-styles.scss +++ b/src/plugin-styles.scss @@ -1,4 +1,5 @@ @import "~@wordpress/base-styles/colors"; +@import "~@wordpress/base-styles/breakpoints"; $modal-footer-height: 70px; @@ -11,8 +12,8 @@ $modal-footer-height: 70px; } .create-block-theme__metadata-editor-modal__footer { - border-top: 1px solid #ddd; - background-color: #fff; + border-top: 1px solid $gray-300; + background-color: $white; position: absolute; bottom: 0; margin: 0 -32px; @@ -27,3 +28,95 @@ $modal-footer-height: 70px; object-fit: cover; } +.create-block-theme__edit-theme-settings-modal { + padding-bottom: $modal-footer-height; + + // Drop the top border on the first PanelBody when it sits below a + // non-panel sibling (e.g. our PaletteSummary card) so the boundary + // doesn't double-up with the summary's bottom edge. + .components-tab-panel__tab-content > :not(.components-panel__body) + .components-panel__body { + border-top: 0; + } +} + +.create-block-theme__edit-theme-settings-modal__footer { + border-top: 1px solid $gray-300; + background-color: $white; + position: absolute; + bottom: 0; + margin: 0 -32px; + padding: 16px 32px; + height: $modal-footer-height; +} + +.cbt-palette-summary { + padding: 8px 0; + margin-bottom: 8px; + + &__swatches > * + * { + // Overlap each subsequent swatch onto its predecessor. + margin-left: -8px; + } + + &__edit { + text-decoration: none; + } +} + +.cbt-color-settings-columns { + // Two side-by-side groups (Default presets / Custom presets) on wider + // modals; collapse to a single column at narrow widths. + display: grid; + grid-template-columns: 1fr; + gap: 32px; + // Top-align so the two group headers (and their first toggle) line up + // even when the columns contain different numbers of toggles. + align-items: start; + + @media (min-width: $break-small) { + grid-template-columns: 1fr 1fr; + } +} + +.cbt-palette-swatch-button { + min-width: 24px; + padding: 0; +} + +.cbt-palette-section-header { + margin-bottom: 8px; + + // Nudge the right-edge action (e.g. "+ Add a color") -8px so it aligns + // with the PanelBody chevron in the accordion header directly above. + > :last-child { + margin-right: -8px; + } +} + +.cbt-palette-column-headers { + // Match the horizontal padding @wordpress/components Item applies to row + // content so column headers line up with the inputs below. + padding: 0 12px; + + &__spacer { + display: inline-block; + width: 24px; + height: 24px; + flex-shrink: 0; + } + + &__label { + display: block; + // The wrapper above already pads horizontally to match the row's + // Item padding, so the label sits at the same x-offset as the + // TextControl input directly below it. + padding-left: 0; + font-size: 11px; + font-weight: 500; + line-height: 1.4; + text-transform: uppercase; + letter-spacing: 0.5px; + color: $gray-700; + } +} + diff --git a/src/resolvers.js b/src/resolvers.js index d0219fc8..2cc4ac0c 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -85,6 +85,17 @@ export async function postUpdateThemeMetadata( theme ) { } ); } +export async function postUpdateThemeSettings( payload ) { + return apiFetch( { + path: '/create-block-theme/v1/theme-settings', + method: 'POST', + data: payload, + headers: { + 'Content-Type': 'application/json', + }, + } ); +} + export async function downloadExportedTheme() { return apiFetch( { path: '/create-block-theme/v1/export',