From cd528cd9c352477423b7bbbc8f7c5b2b0cc74a0c Mon Sep 17 00:00:00 2001 From: Silvano Stralla Date: Fri, 17 Jul 2026 14:11:40 +0200 Subject: [PATCH] web-previews: add visual editing to sidebar previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar previews can now be browsed with click-to-edit overlays, the same way the Visual tab works. Clicking an element navigates the Studio to that record's editor, scrolled to the clicked field. An "Edit mode" toggle in the sidebar toolbar arms the overlay. It's a per-user preference (persisted per site, default off) and is disabled with an explanatory tooltip when the selected preview link's frontend has no visual editing configured. The content-link connection stays alive for the whole life of a supporting link, so flipping the toggle syncs the overlay over the existing connection rather than remounting the iframe — the user keeps whatever page they browsed to. Clicks on records from another environment are ignored. Extracts the content-link transport (types, connection hook, method proxy), normalizePathForVisualEditing, and EditModeToggle out of the Inspector into shared modules, since both entry points now use them. --- .../Browser/Toolbar}/EditModeToggle/index.tsx | 7 +- .../Toolbar}/EditModeToggle/styles.module.css | 0 .../Inspector/ContentLinkContext/index.tsx | 8 +- .../src/entrypoints/Inspector/UI/index.tsx | 4 +- .../src/entrypoints/SidebarFrame/index.tsx | 35 +++++++ .../SidebarFrame/useSidebarContentLink.ts | 98 +++++++++++++++++++ .../contentLink}/types.ts | 0 .../contentLink}/useContentLinkConnection.ts | 0 .../contentLink}/useMethodProxy.ts | 0 .../normalizePathForVisualEditing.ts | 0 .../utils/persistedSidebarVisualEditing.ts | 34 +++++++ web-previews/tsconfig.app.tsbuildinfo | 2 +- 12 files changed, 179 insertions(+), 9 deletions(-) rename web-previews/src/{entrypoints/Inspector/UI => components/Browser/Toolbar}/EditModeToggle/index.tsx (80%) rename web-previews/src/{entrypoints/Inspector/UI => components/Browser/Toolbar}/EditModeToggle/styles.module.css (100%) create mode 100644 web-previews/src/entrypoints/SidebarFrame/useSidebarContentLink.ts rename web-previews/src/{entrypoints/Inspector/ContentLinkContext => utils/contentLink}/types.ts (100%) rename web-previews/src/{entrypoints/Inspector/ContentLinkContext => utils/contentLink}/useContentLinkConnection.ts (100%) rename web-previews/src/{entrypoints/Inspector/ContentLinkContext => utils/contentLink}/useMethodProxy.ts (100%) rename web-previews/src/{entrypoints/Inspector => utils}/normalizePathForVisualEditing.ts (100%) create mode 100644 web-previews/src/utils/persistedSidebarVisualEditing.ts diff --git a/web-previews/src/entrypoints/Inspector/UI/EditModeToggle/index.tsx b/web-previews/src/components/Browser/Toolbar/EditModeToggle/index.tsx similarity index 80% rename from web-previews/src/entrypoints/Inspector/UI/EditModeToggle/index.tsx rename to web-previews/src/components/Browser/Toolbar/EditModeToggle/index.tsx index 6de724a4..e1ced2a3 100644 --- a/web-previews/src/entrypoints/Inspector/UI/EditModeToggle/index.tsx +++ b/web-previews/src/components/Browser/Toolbar/EditModeToggle/index.tsx @@ -5,18 +5,21 @@ import { TooltipContent, TooltipTrigger, } from 'datocms-react-ui'; +import type { ReactNode } from 'react'; import styles from './styles.module.css'; interface EditModeToggleProps { value: boolean; disabled: boolean; - onChange: () => void; + onChange: (value: boolean) => void; + tooltip?: ReactNode; } export function EditModeToggle({ value, disabled, onChange, + tooltip, }: EditModeToggleProps) { return ( @@ -32,7 +35,7 @@ export function EditModeToggle({ - + {tooltip ?? } ); diff --git a/web-previews/src/entrypoints/Inspector/UI/EditModeToggle/styles.module.css b/web-previews/src/components/Browser/Toolbar/EditModeToggle/styles.module.css similarity index 100% rename from web-previews/src/entrypoints/Inspector/UI/EditModeToggle/styles.module.css rename to web-previews/src/components/Browser/Toolbar/EditModeToggle/styles.module.css diff --git a/web-previews/src/entrypoints/Inspector/ContentLinkContext/index.tsx b/web-previews/src/entrypoints/Inspector/ContentLinkContext/index.tsx index 8f8a506e..fab79594 100644 --- a/web-previews/src/entrypoints/Inspector/ContentLinkContext/index.tsx +++ b/web-previews/src/entrypoints/Inspector/ContentLinkContext/index.tsx @@ -12,14 +12,14 @@ import { useState, } from 'react'; import type { Frontend } from '../../../types'; -import { inspectorUrl } from '../../../utils/urls'; -import { normalizePathForVisualEditing } from '../normalizePathForVisualEditing'; import { type ContentLinkMethods, type ContentLinkState, SYMBOL_FOR_PRIMARY_ENVIRONMENT, -} from './types'; -import useContentLinkConnection from './useContentLinkConnection'; +} from '../../../utils/contentLink/types'; +import useContentLinkConnection from '../../../utils/contentLink/useContentLinkConnection'; +import { normalizePathForVisualEditing } from '../../../utils/normalizePathForVisualEditing'; +import { inspectorUrl } from '../../../utils/urls'; type IframeState = { path: string; key: string }; diff --git a/web-previews/src/entrypoints/Inspector/UI/index.tsx b/web-previews/src/entrypoints/Inspector/UI/index.tsx index c2e841d2..353dbf83 100644 --- a/web-previews/src/entrypoints/Inspector/UI/index.tsx +++ b/web-previews/src/entrypoints/Inspector/UI/index.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BrowserWrapper } from '../../../components/Browser/BrowserWrapper'; import { IframeContainer } from '../../../components/Browser/IframeContainer'; import { Toolbar } from '../../../components/Browser/Toolbar'; +import { EditModeToggle } from '../../../components/Browser/Toolbar/EditModeToggle'; import { ToolbarSlot } from '../../../components/Browser/Toolbar/ToolbarSlot'; import type { ViewportSize } from '../../../components/Browser/ViewportCustomizer'; import { ViewportCustomizer } from '../../../components/Browser/ViewportCustomizer'; @@ -17,11 +18,10 @@ import { type Parameters, type Viewport, } from '../../../types'; +import { normalizePathForVisualEditing } from '../../../utils/normalizePathForVisualEditing'; import { inspectorUrl } from '../../../utils/urls'; import { useContentLink } from '../ContentLinkContext'; -import { normalizePathForVisualEditing } from '../normalizePathForVisualEditing'; import AddressBar from './AddressBar'; -import { EditModeToggle } from './EditModeToggle'; const UI: React.FC = () => { const ctx = useCtx(); diff --git a/web-previews/src/entrypoints/SidebarFrame/index.tsx b/web-previews/src/entrypoints/SidebarFrame/index.tsx index 27946bf5..430b9eef 100644 --- a/web-previews/src/entrypoints/SidebarFrame/index.tsx +++ b/web-previews/src/entrypoints/SidebarFrame/index.tsx @@ -13,6 +13,7 @@ import { BrowserWrapper } from '../../components/Browser/BrowserWrapper'; import styles from '../../components/Browser/BrowserWrapper/styles.module.css'; import { IframeContainer } from '../../components/Browser/IframeContainer'; import { Toolbar } from '../../components/Browser/Toolbar'; +import { EditModeToggle } from '../../components/Browser/Toolbar/EditModeToggle'; import { ToolbarButton } from '../../components/Browser/Toolbar/ToolbarButton'; import { ToolbarSlot } from '../../components/Browser/Toolbar/ToolbarSlot'; import type { ViewportSize } from '../../components/Browser/ViewportCustomizer'; @@ -26,12 +27,17 @@ import { type Viewport, } from '../../types'; import { useStatusByFrontend } from '../../utils/common'; +import { usePersistedSidebarVisualEditing } from '../../utils/persistedSidebarVisualEditing'; import { usePersistedSidebarWidth } from '../../utils/persistedWidth'; import { extractRedirectFromDraftModePreviewUrl, inspectorUrl, } from '../../utils/urls'; import { PreviewLinkSelector } from './PreviewLinkSelector'; +import { + linkSupportsVisualEditing, + useSidebarContentLink, +} from './useSidebarContentLink'; type PropTypes = { ctx: RenderItemFormSidebarCtx; @@ -73,6 +79,24 @@ const SidebarFrame = ({ ctx }: PropTypes) => { usePersistedSidebarWidth(ctx.site); + const [editModeEnabled, setEditModeEnabled] = + usePersistedSidebarVisualEditing(ctx.site); + + const { iframeRef } = useSidebarContentLink(ctx, editModeEnabled); + + const currentFrontend = currentPreviewLink + ? frontends.find((f) => f.name === currentPreviewLink.frontendName) + : undefined; + + const currentLinkSupportsVisualEditing = linkSupportsVisualEditing( + currentPreviewLink, + currentFrontend, + ); + + const editModeTooltip = currentLinkSupportsVisualEditing + ? 'Click elements in the preview to open their record editor' + : "This preview link's frontend doesn't support visual editing"; + const allPreviewLinksWithFrontend = useMemo(() => { if (!statusByFrontend) return []; @@ -194,6 +218,14 @@ const SidebarFrame = ({ ctx }: PropTypes) => { + + + )} @@ -210,6 +242,9 @@ const SidebarFrame = ({ ctx }: PropTypes) => { .+?)(?:\/environments\/(?[^/]+))?\/editor\/item_types\/(?[^/]+)\/items\/(?[^/]+)\/edit#fieldPath=(?.+)$/; + +function itemEditorUrl(ctx: RenderItemFormSidebarCtx, info: EditUrlInfo) { + // Mirror inspectorUrl()'s environment-prefix convention (utils/urls.tsx), and + // match the record-editor route that DatoCMS edit URLs use (ends in /edit). + const prefix = ctx.isEnvironmentPrimary + ? '' + : `/environments/${ctx.environment}`; + + const url = `${prefix}/editor/item_types/${info.itemTypeId}/items/${info.itemId}/edit`; + + // The #fieldPath hash makes the Studio scroll to and highlight the specific + // field — essential when the clicked element belongs to the record already + // open in the form. + return info.fieldPath ? `${url}#fieldPath=${info.fieldPath}` : url; +} + +/** + * Wires a sidebar preview iframe to @datocms/content-link. Unlike the Inspector + * (which opens records inside an inspector panel), a click here navigates the + * whole Studio to the record's editor page. + */ +export function useSidebarContentLink( + ctx: RenderItemFormSidebarCtx, + editModeEnabled: boolean, +) { + const currentEnvironmentId = ctx.isEnvironmentPrimary + ? SYMBOL_FOR_PRIMARY_ENVIRONMENT + : ctx.environment; + + const { iframeRef, connection } = useContentLinkConnection({ + onInit: () => ({ + editUrlRegExp: { + source: editUrlRegExp.source, + flags: editUrlRegExp.flags, + }, + }), + // The sidebar has no "records in this page" panel to sync. + onStateChange: () => {}, + onPing: () => {}, + openItem: async (info) => { + // Ignore clicks on records that belong to a different environment. + if (info.environment !== currentEnvironmentId) { + return; + } + + await ctx.navigateTo(itemEditorUrl(ctx, info)); + }, + }); + + // Keep the click-to-edit overlay in sync with the toggle. The connection + // itself stays alive regardless of the toggle, so flipping it never reloads + // the iframe — the user keeps whatever page they browsed to. + useEffect(() => { + if (connection.type !== 'connected') { + return; + } + + connection.methods.setClickToEditEnabled( + editModeEnabled + ? { enabled: true, flash: { scrollToNearestTarget: false } } + : { enabled: false }, + ); + }, [connection, editModeEnabled]); + + return { iframeRef }; +} diff --git a/web-previews/src/entrypoints/Inspector/ContentLinkContext/types.ts b/web-previews/src/utils/contentLink/types.ts similarity index 100% rename from web-previews/src/entrypoints/Inspector/ContentLinkContext/types.ts rename to web-previews/src/utils/contentLink/types.ts diff --git a/web-previews/src/entrypoints/Inspector/ContentLinkContext/useContentLinkConnection.ts b/web-previews/src/utils/contentLink/useContentLinkConnection.ts similarity index 100% rename from web-previews/src/entrypoints/Inspector/ContentLinkContext/useContentLinkConnection.ts rename to web-previews/src/utils/contentLink/useContentLinkConnection.ts diff --git a/web-previews/src/entrypoints/Inspector/ContentLinkContext/useMethodProxy.ts b/web-previews/src/utils/contentLink/useMethodProxy.ts similarity index 100% rename from web-previews/src/entrypoints/Inspector/ContentLinkContext/useMethodProxy.ts rename to web-previews/src/utils/contentLink/useMethodProxy.ts diff --git a/web-previews/src/entrypoints/Inspector/normalizePathForVisualEditing.ts b/web-previews/src/utils/normalizePathForVisualEditing.ts similarity index 100% rename from web-previews/src/entrypoints/Inspector/normalizePathForVisualEditing.ts rename to web-previews/src/utils/normalizePathForVisualEditing.ts diff --git a/web-previews/src/utils/persistedSidebarVisualEditing.ts b/web-previews/src/utils/persistedSidebarVisualEditing.ts new file mode 100644 index 00000000..c3397c6c --- /dev/null +++ b/web-previews/src/utils/persistedSidebarVisualEditing.ts @@ -0,0 +1,34 @@ +import type { Site } from 'datocms-plugin-sdk'; +import { useCallback, useState } from 'react'; + +function keyForSidebarVisualEditing(site: Site) { + return `site.${site.id}.sidebarVisualEditing`; +} + +export function readSidebarVisualEditing(site: Site): boolean { + // Per-user preference, disabled by default (opt-in). + return localStorage.getItem(keyForSidebarVisualEditing(site)) === 'true'; +} + +function saveSidebarVisualEditing(site: Site, enabled: boolean) { + localStorage.setItem(keyForSidebarVisualEditing(site), String(enabled)); +} + +/** + * Per-user toggle for enabling visual-editing navigation inside the sidebar + * preview. Persisted in localStorage (keyed by site), mirroring the sidebar + * width preference. + */ +export function usePersistedSidebarVisualEditing(site: Site) { + const [enabled, setEnabled] = useState(() => readSidebarVisualEditing(site)); + + const setPersisted = useCallback( + (value: boolean) => { + setEnabled(value); + saveSidebarVisualEditing(site, value); + }, + [site], + ); + + return [enabled, setPersisted] as const; +} diff --git a/web-previews/tsconfig.app.tsbuildinfo b/web-previews/tsconfig.app.tsbuildinfo index c5a67190..073bb36d 100644 --- a/web-previews/tsconfig.app.tsbuildinfo +++ b/web-previews/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/browser/browserwrapper/index.tsx","./src/components/browser/iframecontainer/iframestyles.ts","./src/components/browser/iframecontainer/index.tsx","./src/components/browser/iframecontainer/useiframescaling.ts","./src/components/browser/toolbar/index.tsx","./src/components/browser/toolbar/toolbarbutton/index.tsx","./src/components/browser/toolbar/toolbarslot/index.tsx","./src/components/browser/viewportcustomizer/index.tsx","./src/components/browser/viewportselector/index.tsx","./src/components/buttongroup/index.tsx","./src/entrypoints/configscreen/index.tsx","./src/entrypoints/configscreen/iconpickerinput/iconpickermodal.tsx","./src/entrypoints/configscreen/iconpickerinput/index.tsx","./src/entrypoints/configscreen/components/customheaderslist.tsx","./src/entrypoints/configscreen/components/frontendfielditem.tsx","./src/entrypoints/configscreen/components/previewlinkssection.tsx","./src/entrypoints/configscreen/components/previewlinkssettings.tsx","./src/entrypoints/configscreen/components/viewportfielditem.tsx","./src/entrypoints/configscreen/components/visualeditingsection.tsx","./src/entrypoints/inspector/index.tsx","./src/entrypoints/inspector/normalizepathforvisualediting.ts","./src/entrypoints/inspector/contentlinkcontext/index.tsx","./src/entrypoints/inspector/contentlinkcontext/types.ts","./src/entrypoints/inspector/contentlinkcontext/usecontentlinkconnection.ts","./src/entrypoints/inspector/contentlinkcontext/usemethodproxy.ts","./src/entrypoints/inspector/ui/index.tsx","./src/entrypoints/inspector/ui/addressbar/index.tsx","./src/entrypoints/inspector/ui/addressbar/frontendselector/index.tsx","./src/entrypoints/inspector/ui/editmodetoggle/index.tsx","./src/entrypoints/inspectorloading/index.tsx","./src/entrypoints/sidebarframe/index.tsx","./src/entrypoints/sidebarframe/previewlinkselector/index.tsx","./src/entrypoints/sidebarframe/previewlinkselector/frontendgroup/index.tsx","./src/entrypoints/sidebarframe/previewlinkselector/frontendpreviewlinks/index.tsx","./src/entrypoints/sidebarframe/previewlinkselector/trigger/index.tsx","./src/entrypoints/sidebarpanel/index.tsx","./src/entrypoints/wrongenvironmentpanel/index.tsx","./src/utils/common.ts","./src/utils/persistedwidth.ts","./src/utils/render.tsx","./src/utils/urls.tsx","./penpal.d.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/browser/browserwrapper/index.tsx","./src/components/browser/iframecontainer/iframestyles.ts","./src/components/browser/iframecontainer/index.tsx","./src/components/browser/iframecontainer/useiframescaling.ts","./src/components/browser/toolbar/index.tsx","./src/components/browser/toolbar/editmodetoggle/index.tsx","./src/components/browser/toolbar/toolbarbutton/index.tsx","./src/components/browser/toolbar/toolbarslot/index.tsx","./src/components/browser/viewportcustomizer/index.tsx","./src/components/browser/viewportselector/index.tsx","./src/components/buttongroup/index.tsx","./src/entrypoints/configscreen/index.tsx","./src/entrypoints/configscreen/iconpickerinput/iconpickermodal.tsx","./src/entrypoints/configscreen/iconpickerinput/index.tsx","./src/entrypoints/configscreen/components/customheaderslist.tsx","./src/entrypoints/configscreen/components/frontendfielditem.tsx","./src/entrypoints/configscreen/components/previewlinkssection.tsx","./src/entrypoints/configscreen/components/previewlinkssettings.tsx","./src/entrypoints/configscreen/components/viewportfielditem.tsx","./src/entrypoints/configscreen/components/visualeditingsection.tsx","./src/entrypoints/inspector/index.tsx","./src/entrypoints/inspector/contentlinkcontext/index.tsx","./src/entrypoints/inspector/ui/index.tsx","./src/entrypoints/inspector/ui/addressbar/index.tsx","./src/entrypoints/inspector/ui/addressbar/frontendselector/index.tsx","./src/entrypoints/inspectorloading/index.tsx","./src/entrypoints/sidebarframe/index.tsx","./src/entrypoints/sidebarframe/usesidebarcontentlink.ts","./src/entrypoints/sidebarframe/previewlinkselector/index.tsx","./src/entrypoints/sidebarframe/previewlinkselector/frontendgroup/index.tsx","./src/entrypoints/sidebarframe/previewlinkselector/frontendpreviewlinks/index.tsx","./src/entrypoints/sidebarframe/previewlinkselector/trigger/index.tsx","./src/entrypoints/sidebarpanel/index.tsx","./src/entrypoints/wrongenvironmentpanel/index.tsx","./src/utils/common.ts","./src/utils/normalizepathforvisualediting.ts","./src/utils/persistedsidebarvisualediting.ts","./src/utils/persistedwidth.ts","./src/utils/render.tsx","./src/utils/urls.tsx","./src/utils/contentlink/types.ts","./src/utils/contentlink/usecontentlinkconnection.ts","./src/utils/contentlink/usemethodproxy.ts","./penpal.d.ts"],"version":"5.9.3"} \ No newline at end of file