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