Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Tooltip>
Expand All @@ -32,7 +35,7 @@ export function EditModeToggle({
</label>
</TooltipTrigger>
<TooltipContent>
<HotKey label="Enable edit overlay" hotkey="alt" />
{tooltip ?? <HotKey label="Enable edit overlay" hotkey="alt" />}
</TooltipContent>
</Tooltip>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
4 changes: 2 additions & 2 deletions web-previews/src/entrypoints/Inspector/UI/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<RenderInspectorCtx>();
Expand Down
35 changes: 35 additions & 0 deletions web-previews/src/entrypoints/SidebarFrame/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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 [];

Expand Down Expand Up @@ -194,6 +218,14 @@ const SidebarFrame = ({ ctx }: PropTypes) => {
</ButtonGroupButton>
</ButtonGroup>
</ToolbarSlot>
<ToolbarSlot withLeftBorder>
<EditModeToggle
value={editModeEnabled}
disabled={!currentLinkSupportsVisualEditing}
tooltip={editModeTooltip}
onChange={setEditModeEnabled}
/>
</ToolbarSlot>
</>
)}
</Toolbar>
Expand All @@ -210,6 +242,9 @@ const SidebarFrame = ({ ctx }: PropTypes) => {
<IframeContainer
key={`${currentPreviewLink.url}-${reloadCounter}`}
src={currentPreviewLink.url}
iframeRef={
currentLinkSupportsVisualEditing ? iframeRef : undefined
}
allow={iframeAllowAttribute}
sizing={
currentViewport === 'responsive'
Expand Down
98 changes: 98 additions & 0 deletions web-previews/src/entrypoints/SidebarFrame/useSidebarContentLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { RenderItemFormSidebarCtx } from 'datocms-plugin-sdk';
import { useEffect } from 'react';
import type { Frontend, PreviewLinkWithFrontend } from '../../types';
import {
type EditUrlInfo,
SYMBOL_FOR_PRIMARY_ENVIRONMENT,
} from '../../utils/contentLink/types';
import useContentLinkConnection from '../../utils/contentLink/useContentLinkConnection';
import { extractRedirectFromDraftModePreviewUrl } from '../../utils/urls';

/**
* A preview link supports visual editing when its frontend has draft mode
* configured and the link URL is the draft-mode route (a page where
* @datocms/content-link is active). Same gate the "Open in Visual" button uses.
*/
export function linkSupportsVisualEditing(
link: PreviewLinkWithFrontend | undefined,
frontend: Frontend | undefined,
): boolean {
const enableDraftModeUrl = frontend?.visualEditing?.enableDraftModeUrl;
if (!link || !enableDraftModeUrl) {
return false;
}
return Boolean(
extractRedirectFromDraftModePreviewUrl(link.url, enableDraftModeUrl),
);
}

// Same pattern the Inspector sends to @datocms/content-link on init: it tells
// the website how to recognise a DatoCMS edit URL and extract its parts.
const editUrlRegExp =
/^(?<base_url>.+?)(?:\/environments\/(?<environment>[^/]+))?\/editor\/item_types\/(?<item_type_id>[^/]+)\/items\/(?<item_id>[^/]+)\/edit#fieldPath=(?<field_path>.+)$/;

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 };
}
34 changes: 34 additions & 0 deletions web-previews/src/utils/persistedSidebarVisualEditing.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion web-previews/tsconfig.app.tsbuildinfo
Original file line number Diff line number Diff line change
@@ -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"}
{"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"}