diff --git a/.changeset/visual-editing-toggle.md b/.changeset/visual-editing-toggle.md new file mode 100644 index 0000000..33df593 --- /dev/null +++ b/.changeset/visual-editing-toggle.md @@ -0,0 +1,5 @@ +--- +"react-datocms": minor +--- + +Add a Visual Editing toolbar: `` (or `` with `useContentLink`) shows a floating, draggable bar to turn click-to-edit on and off, highlight editable elements and, with `exitDraftModeUrl`, exit draft mode. The editor's choice is remembered. `useContentLink` now re-renders once its `controller` exists, so the returned `controller` is no longer `null` after mount. `react-dom` is now a peer dependency. diff --git a/docs/content-link.md b/docs/content-link.md index 0fd0c33..d47de87 100644 --- a/docs/content-link.md +++ b/docs/content-link.md @@ -25,6 +25,7 @@ Visual Editing works by: - [1. Via prop (persistent)](#1-via-prop-persistent) - [2. Via keyboard shortcut (temporary)](#2-via-keyboard-shortcut-temporary) - [Flash-all highlighting](#flash-all-highlighting) +- [Visual Editing toolbar](#visual-editing-toolbar) - [Props](#props) - [Advanced usage: the `useContentLink` hook](#advanced-usage-the-usecontentlink-hook) - [Hook API](#hook-api) @@ -259,6 +260,59 @@ function DebugButton() { The `true` parameter scrolls to the nearest editable element, useful on long pages. +## Visual Editing toolbar + +Pass `toggle` to show a small floating toolbar that tells editors whether click-to-edit is on, and lets them change it: + +```jsx + +``` + +The toolbar has: + +- an on/off switch, with a hint about the Alt/Option shortcut (it also shows when the overlays are only temporarily on or off because Alt/Option is held) +- **Show editable**, which highlights all editable elements (`flashAll`) +- **Exit preview**, a link to `exitDraftModeUrl` (only when you pass it) +- a collapse button + +Editors can drag it anywhere (double-click or the Home key puts it back in its corner, arrow keys move it from the keyboard). Their on/off choice, collapsed state and position are remembered in `localStorage`, so the choice wins over `enableClickToEdit`, which becomes the starting state for editors who haven't chosen yet. `hoverOnly` keeps working: on touch devices the toolbar starts with click-to-edit off. + +The toolbar is rendered in a Shadow DOM (your CSS can't affect it, and it can't affect your site) and uses the same color as the overlays (`hue`). It renders nothing inside iframes, where the [Web Previews plugin](https://www.datocms.com/marketplace/plugins/i/datocms-plugin-web-previews) has its own controls. + +Only render `` when draft mode is on, so the toolbar is never shown to visitors. + +**Options (`VisualEditingToggleOptions`):** + +| Option | Type | Default | Description | +| ------------------ | -------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------- | +| `exitDraftModeUrl` | `string` | - | URL of your "disable draft mode" route. Adds an **Exit preview** link | +| `position` | `'top-right' \| 'top-left' \| 'bottom-right' \| 'bottom-left'` | `'top-right'` | Corner the toolbar starts in | +| `storageKey` | `string` | `'datocms-visual-editing-enabled'` | `localStorage` key. Collapsed state and position use `${storageKey}-collapsed` and `-position` | + +If you use the `useContentLink` hook instead of the component, render `` yourself: + +```jsx +import { useContentLink, VisualEditingToggle } from 'react-datocms'; + +function VisualEditing() { + const { controller } = useContentLink(); + + return controller ? ( + + ) : null; +} +``` + +On top of the options above, `` takes `controller`, `defaultEnabled` (default `true`), `hoverOnly`, `scrollToNearestTarget` and `hue`. + +The toolbar requires React 17 or later. + ## Props The `` component accepts the following props: @@ -271,6 +325,7 @@ The `` component accepts the following props: | `stripStega` | `boolean` | - | Whether to strip stega encoding from text nodes after stamping | | `root` | `React.RefObject` | - | Ref to limit scanning to this root element instead of the entire document | | `hue` | `number` | `17` | Hue (0–359) of the overlay accent color. Default is the DatoCMS hue (`17`). Use this to match your brand or project colors | +| `toggle` | `boolean \| VisualEditingToggleOptions` | - | Show the [Visual Editing toolbar](#visual-editing-toolbar). Pass `true` or an object with options | ## Advanced usage: the `useContentLink` hook diff --git a/package-lock.json b/package-lock.json index a08a97c..d4a245d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,8 @@ }, "peerDependencies": { "@mux/mux-player-react": ">=2.5.0", - "react": ">= 16.12.0" + "react": ">= 16.12.0", + "react-dom": ">= 16.12.0" }, "peerDependenciesMeta": { "@mux/mux-player-react": { diff --git a/package.json b/package.json index 2757103..805f6ca 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,8 @@ }, "peerDependencies": { "@mux/mux-player-react": ">=2.5.0", - "react": ">= 16.12.0" + "react": ">= 16.12.0", + "react-dom": ">= 16.12.0" }, "peerDependenciesMeta": { "@mux/mux-player-react": { diff --git a/src/ContentLink/VisualEditingToggle/index.tsx b/src/ContentLink/VisualEditingToggle/index.tsx new file mode 100644 index 0000000..85940da --- /dev/null +++ b/src/ContentLink/VisualEditingToggle/index.tsx @@ -0,0 +1,478 @@ +'use client'; + +import type { Controller } from '@datocms/content-link'; +import React, { + type KeyboardEvent, + type MouseEvent, + type PointerEvent, + type ReactElement, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { buildStyles } from './styles.js'; +import { + type Position, + isInIframe, + overlayColor, + readBoolean, + readPosition, + writeBoolean, + writePosition, +} from './utils.js'; + +// Dispatched on `document` by @datocms/content-link whenever click-to-edit turns on or +// off, including the temporary Alt/Option state. Not exported by the library. +const TOGGLE_EVENT = 'datocms:click-to-edit:toggle'; +const DEFAULT_STORAGE_KEY = 'datocms-visual-editing-enabled'; +// Minimum distance from the viewport edges. +const EDGE = 8; +const KEY_STEPS: Record = { + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + ArrowUp: [0, -1], + ArrowDown: [0, 1], +}; + +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +export type VisualEditingTogglePosition = + | 'top-right' + | 'top-left' + | 'bottom-right' + | 'bottom-left'; + +export type VisualEditingToggleOptions = { + /** + * URL of your app's "disable draft mode" route. When set, the toggle shows an + * "Exit preview" link pointing to it. + */ + exitDraftModeUrl?: string; + /** + * Corner the toggle starts in (and goes back to on double-click / Home key). + * @default 'top-right' + */ + position?: VisualEditingTogglePosition; + /** + * localStorage key for the editor's on/off choice. Collapsed state and position + * are stored under `${storageKey}-collapsed` and `${storageKey}-position`. + * @default 'datocms-visual-editing-enabled' + */ + storageKey?: string; +}; + +export type VisualEditingToggleProps = VisualEditingToggleOptions & { + /** The Content Link controller, e.g. `useContentLink().controller` */ + controller: Controller; + /** + * Whether click-to-edit starts on when the editor hasn't chosen yet. + * @default true + */ + defaultEnabled?: boolean; + /** + * Start off on devices without hover (touch), like `enableClickToEdit({ hoverOnly: true })`. + * A choice the editor made with the switch still wins. + * @default false + */ + hoverOnly?: boolean; + /** Scroll to the nearest editable element when click-to-edit starts on. */ + scrollToNearestTarget?: boolean; + /** + * Hue (0–359) of the accent color. Pass the same hue as the controller, so the + * toggle matches the overlays. + * @default 17 (orange) + */ + hue?: number; +}; + +/** + * Floating toolbar that shows whether Visual Editing (click-to-edit overlays) is on, + * and lets editors turn it on and off, highlight editable elements and, optionally, + * exit draft mode. + * + * Render it only when draft mode is on. It renders nothing inside iframes, where the + * Web Previews plugin has its own toggle. Remembers the editor's choice, collapsed + * state and position in localStorage. Requires React 17 or later. + * + * Usually used through ``. + */ +export function VisualEditingToggle( + props: VisualEditingToggleProps, +): ReactElement | null { + const { + controller, + defaultEnabled = true, + hoverOnly = false, + scrollToNearestTarget = false, + hue = 17, + exitDraftModeUrl, + position = 'top-right', + storageKey = DEFAULT_STORAGE_KEY, + } = props; + + const collapsedKey = `${storageKey}-collapsed`; + const positionKey = `${storageKey}-position`; + + const [shadowRoot, setShadowRoot] = useState(null); + // null until the stored preference has been applied to the controller + const [enabled, setEnabled] = useState(null); + const [temporary, setTemporary] = useState(false); + const [expanded, setExpanded] = useState(true); + const [dragging, setDragging] = useState(false); + const [announcement, setAnnouncement] = useState(''); + + const widgetRef = useRef(null); + // The editor's chosen state. Holding Alt/Option can temporarily diverge from it. + const preferredRef = useRef(defaultEnabled); + // Only an Alt-held divergence is "temporary": anything else (e.g. app code calling + // enableClickToEdit) becomes the new preferred state for this page view. + const altHeldRef = useRef(false); + // Where the editor dropped it (top-left corner, px). null = default corner. + const positionRef = useRef(null); + const dragRef = useRef<{ offsetX: number; offsetY: number } | null>(null); + const announcedRef = useRef(null); + + const styles = useMemo(() => buildStyles(overlayColor(hue)), [hue]); + + const sync = useCallback(() => { + const isEnabled = controller.isClickToEditEnabled(); + if (!altHeldRef.current) { + preferredRef.current = isEnabled; + } + setEnabled(isEnabled); + setTemporary(altHeldRef.current && isEnabled !== preferredRef.current); + }, [controller]); + + // Keeps the widget fully on screen. Only the rendered position is clamped: the + // saved one is kept, so it moves back when the window grows again. + const applyPosition = useCallback(() => { + const widget = widgetRef.current; + if (!widget) { + return; + } + const saved = positionRef.current; + if (!saved) { + Object.assign(widget.style, { left: '', top: '', right: '', bottom: '' }); + return; + } + const { width, height } = widget.getBoundingClientRect(); + const x = Math.min( + Math.max(saved.x, EDGE), + window.innerWidth - width - EDGE, + ); + const y = Math.min( + Math.max(saved.y, EDGE), + window.innerHeight - height - EDGE, + ); + Object.assign(widget.style, { + left: `${x}px`, + top: `${y}px`, + right: 'auto', + bottom: 'auto', + }); + }, []); + + // Rendered position as the editor sees it, saved as the new position. + const savePosition = useCallback(() => { + const widget = widgetRef.current; + if (!widget || !positionRef.current) { + return; + } + const { left, top } = widget.getBoundingClientRect(); + positionRef.current = { x: left, y: top }; + writePosition(positionKey, positionRef.current); + }, [positionKey]); + + const resetPosition = useCallback(() => { + positionRef.current = null; + writePosition(positionKey, null); + applyPosition(); + }, [positionKey, applyPosition]); + + // Host element + Shadow DOM, top-level window only. + useEffect(() => { + if (isInIframe()) { + return; + } + const host = document.createElement('div'); + host.setAttribute('data-datocms-visual-editing-toggle', ''); + const root = host.attachShadow({ mode: 'open' }); + document.body.appendChild(host); + setShadowRoot(root); + return () => { + host.remove(); + }; + }, []); + + // Restore collapsed state and position. + useEffect(() => { + setExpanded(!readBoolean(collapsedKey, false)); + positionRef.current = readPosition(positionKey); + applyPosition(); + }, [collapsedKey, positionKey, applyPosition]); + + // Apply the editor's stored choice whenever there's a (new) controller. + // biome-ignore lint/correctness/useExhaustiveDependencies: defaults only apply on (re)creation + useEffect(() => { + if (isInIframe()) { + return; + } + const canHover = + typeof window.matchMedia !== 'function' || + window.matchMedia('(hover: hover)').matches; + const preferred = readBoolean( + storageKey, + hoverOnly && !canHover ? false : defaultEnabled, + ); + preferredRef.current = preferred; + if (preferred) { + controller.enableClickToEdit( + scrollToNearestTarget ? { scrollToNearestTarget } : undefined, + ); + } else { + controller.disableClickToEdit(); + } + sync(); + }, [controller, storageKey]); + + // Follow the controller (Alt/Option, app code) and the Alt key itself. + useEffect(() => { + // window + capture phase: runs before the library's own Alt handler (on + // document), so the toggle event it dispatches already sees altHeld. + const onKey = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Alt') { + altHeldRef.current = event.type === 'keydown'; + } + }; + const onBlur = () => { + altHeldRef.current = false; + }; + window.addEventListener('keydown', onKey, true); + window.addEventListener('keyup', onKey, true); + window.addEventListener('blur', onBlur); + document.addEventListener(TOGGLE_EVENT, sync); + return () => { + window.removeEventListener('keydown', onKey, true); + window.removeEventListener('keyup', onKey, true); + window.removeEventListener('blur', onBlur); + document.removeEventListener(TOGGLE_EVENT, sync); + }; + }, [sync]); + + useEffect(() => { + window.addEventListener('resize', applyPosition); + return () => window.removeEventListener('resize', applyPosition); + }, [applyPosition]); + + // Size changes with state (collapse, labels): re-clamp after every render. + useIsomorphicLayoutEffect(() => { + applyPosition(); + }); + + // Announce changes to screen readers, not the initial state. + useEffect(() => { + if (enabled === null) { + return; + } + const state = `${enabled}-${temporary}`; + if (announcedRef.current !== null && announcedRef.current !== state) { + setAnnouncement( + `Visual editing ${enabled ? 'on' : 'off'}${ + temporary ? ', temporarily' : '' + }`, + ); + } + announcedRef.current = state; + }, [enabled, temporary]); + + const isOnControl = (target: EventTarget) => + target instanceof Element && target.closest('button, a') !== null; + + // Drag anywhere on the pill except its controls. + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0 || isOnControl(event.target)) { + return; + } + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + dragRef.current = { + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + event.currentTarget.setPointerCapture?.(event.pointerId); + setDragging(true); + }; + + const onPointerMove = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag) { + return; + } + positionRef.current = { + x: event.clientX - drag.offsetX, + y: event.clientY - drag.offsetY, + }; + applyPosition(); + }; + + const onPointerUp = () => { + if (!dragRef.current) { + return; + } + dragRef.current = null; + setDragging(false); + savePosition(); + }; + + // Listens on the pill itself: pointer capture retargets the dblclick to it. + const onDoubleClick = (event: MouseEvent) => { + if (!isOnControl(event.target)) { + resetPosition(); + } + }; + + // Keyboard equivalent of dragging, on the focusable grip. + const onGripKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Home') { + event.preventDefault(); + resetPosition(); + return; + } + const step = KEY_STEPS[event.key]; + const widget = widgetRef.current; + if (!step || !widget) { + return; + } + event.preventDefault(); + const distance = event.shiftKey ? 50 : 10; + const { left, top } = widget.getBoundingClientRect(); + positionRef.current = { + x: left + step[0] * distance, + y: top + step[1] * distance, + }; + applyPosition(); + savePosition(); + }; + + const onSwitchClick = () => { + const next = !controller.isClickToEditEnabled(); + preferredRef.current = next; + writeBoolean(storageKey, next); + if (next) { + controller.enableClickToEdit(); + } else { + controller.disableClickToEdit(); + } + sync(); + }; + + const onCollapseClick = () => { + writeBoolean(collapsedKey, expanded); + setExpanded(!expanded); + }; + + if (!shadowRoot || enabled === null) { + return null; + } + + const className = [ + 'widget', + ...position.split('-'), + enabled && 'on', + temporary && 'temp', + dragging && 'dragging', + ] + .filter(Boolean) + .join(' '); + + return createPortal( + <> + +
+ + ⋮⋮ + + + + {expanded && ( + {`Visual editing ${ + enabled ? 'on' : 'off' + }`} + )} + + )} + {expanded && exitDraftModeUrl && ( + + Exit preview + + )} + + + {announcement} + +
+ , + shadowRoot, + ); +} diff --git a/src/ContentLink/VisualEditingToggle/styles.ts b/src/ContentLink/VisualEditingToggle/styles.ts new file mode 100644 index 0000000..950c6c2 --- /dev/null +++ b/src/ContentLink/VisualEditingToggle/styles.ts @@ -0,0 +1,56 @@ +// Lives inside the widget's Shadow DOM: site CSS can't reach it, and it can't +// reach the site. +export function buildStyles(accent: string): string { + return ` + :host { all: initial; } + .widget { + position: fixed; z-index: 2147483647; + cursor: grab; touch-action: none; user-select: none; + display: flex; align-items: center; gap: 10px; + padding: 6px 6px 6px 8px; border-radius: 6px; + background: ${accent}; color: #fff; + font: 600 13px/1.2 system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + box-shadow: 0 4px 12px rgb(0 0 0 / 0.15), 0 1px 3px rgb(0 0 0 / 0.1); + } + .widget.top { top: 16px; } + .widget.bottom { bottom: 16px; } + .widget.left { left: 16px; } + .widget.right { right: 16px; } + .widget.dragging { cursor: grabbing; box-shadow: 0 8px 24px rgb(0 0 0 / 0.25); } + .grip { color: rgb(255 255 255 / 0.6); font-size: 12px; letter-spacing: -2px; border-radius: 2px; } + .grip:focus-visible { outline: 2px solid #fff; outline-offset: 2px; color: #fff; } + .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + .text { display: flex; flex-direction: column; gap: 3px; } + .row { display: flex; align-items: center; gap: 8px; } + .label { white-space: nowrap; } + .hint { font-weight: 400; font-size: 11px; color: rgb(255 255 255 / 0.85); white-space: nowrap; } + .temp .hint { color: #fff; font-weight: 600; } + button, a { + all: unset; cursor: pointer; box-sizing: border-box; white-space: nowrap; + border-radius: 4px; transition: opacity 0.2s; + } + button:hover, a:hover { opacity: 0.85; } + button:active, a:active { opacity: 0.7; } + button:disabled { opacity: 0.5; cursor: not-allowed; } + button:focus-visible, a:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } + .flash { padding: 6px 10px; background: #fff; color: ${accent}; } + .exit { padding: 5px 9px; border: 1px solid rgb(255 255 255 / 0.7); } + .switch { + position: relative; flex: none; width: 32px; height: 18px; border-radius: 18px; + background: rgb(0 0 0 / 0.25); transition: background 0.2s; + } + .switch::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 14px; height: 14px; border-radius: 50%; background: #fff; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.2); transition: left 0.2s; + } + .on .switch { background: #fff; } + .on .switch::after { left: 16px; background: ${accent}; } + .collapse { + width: 26px; height: 26px; display: grid; place-items: center; + color: #fff; font-size: 16px; font-weight: 400; + } + .collapse:hover { background: rgb(0 0 0 / 0.15); opacity: 1; } + `; +} diff --git a/src/ContentLink/VisualEditingToggle/utils.ts b/src/ContentLink/VisualEditingToggle/utils.ts new file mode 100644 index 0000000..7330c24 --- /dev/null +++ b/src/ContentLink/VisualEditingToggle/utils.ts @@ -0,0 +1,84 @@ +export type Position = { x: number; y: number }; + +/** Inside an iframe (e.g. the Web Previews plugin), which has its own toggle. */ +export function isInIframe(): boolean { + return window.parent !== window; +} + +// Storage can be missing (SSR) or throw (private mode, blocked site data): in both +// cases the widget still works, it just doesn't remember anything. + +export function readBoolean(key: string, fallback: boolean): boolean { + try { + const value = window.localStorage.getItem(key); + return value === null ? fallback : value === 'true'; + } catch { + return fallback; + } +} + +export function writeBoolean(key: string, value: boolean): void { + try { + window.localStorage.setItem(key, String(value)); + } catch {} +} + +export function readPosition(key: string): Position | null { + try { + const value = JSON.parse(window.localStorage.getItem(key) || 'null'); + return value && Number.isFinite(value.x) && Number.isFinite(value.y) + ? { x: value.x, y: value.y } + : null; + } catch { + return null; + } +} + +export function writePosition(key: string, value: Position | null): void { + try { + if (value) { + window.localStorage.setItem(key, JSON.stringify(value)); + } else { + window.localStorage.removeItem(key); + } + } catch {} +} + +/** + * Same color as the Content Link overlay borders and "Open in DatoCMS" labels. + * + * Copied from @datocms/content-link (src/createController/clickToEdit/constants.ts), + * which doesn't export it: the darkest-needed HSL lightness (S=100%) that keeps + * white text at ≥3.5:1 contrast. + */ +export function overlayColor(hue: number): string { + const hslToRgb = (h: number, l: number) => { + const a = Math.min(l, 1 - l); + const f = (n: number) => { + const k = (n + h / 30) % 12; + return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1))); + }; + return [f(0), f(8), f(4)].map((c) => Math.round(c * 255)); + }; + const luminance = ([r, g, b]: number[]) => { + const lin = (c: number) => + c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + return ( + 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255) + ); + }; + + let lo = 0; + let hi = 0.5; + let lightness = 0; + while (hi - lo > 0.001) { + const mid = (lo + hi) / 2; + if (1.05 / (luminance(hslToRgb(hue, mid)) + 0.05) >= 3.5) { + lightness = mid; + lo = mid + 0.001; + } else { + hi = mid - 0.001; + } + } + return `hsl(${hue}, 100%, ${Math.round(lightness * 100)}%)`; +} diff --git a/src/ContentLink/__tests__/VisualEditingToggle.test.tsx b/src/ContentLink/__tests__/VisualEditingToggle.test.tsx new file mode 100644 index 0000000..667f40c --- /dev/null +++ b/src/ContentLink/__tests__/VisualEditingToggle.test.tsx @@ -0,0 +1,298 @@ +import type { Controller } from '@datocms/content-link'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { act } from 'react-dom/test-utils'; +import { + VisualEditingToggle, + type VisualEditingToggleProps, +} from '../VisualEditingToggle/index.js'; +import * as utils from '../VisualEditingToggle/utils.js'; + +const TOGGLE_EVENT = 'datocms:click-to-edit:toggle'; + +// Behaves like the real controller: every change dispatches the toggle event. +function fakeController() { + let enabled = false; + const set = (value: boolean) => { + enabled = value; + document.dispatchEvent(new CustomEvent(TOGGLE_EVENT, { detail: value })); + }; + return { + enableClickToEdit: jest.fn(() => set(true)), + disableClickToEdit: jest.fn(() => set(false)), + isClickToEditEnabled: () => enabled, + flashAll: jest.fn(), + // What the library does while Alt/Option is held + simulateAlt: (value: boolean) => set(value), + }; +} + +type Fake = ReturnType; + +let container: HTMLDivElement; + +function render( + controller: Fake, + props: Partial = {}, +) { + act(() => { + ReactDOM.render( + , + container, + ); + }); +} + +function unmount() { + act(() => { + ReactDOM.unmountComponentAtNode(container); + }); +} + +function shadow(): ShadowRoot | null { + return ( + document.querySelector('[data-datocms-visual-editing-toggle]') + ?.shadowRoot ?? null + ); +} + +function $(selector: string): HTMLElement | null { + return shadow()?.querySelector(selector) ?? null; +} + +function click(selector: string) { + act(() => { + $(selector)?.click(); + }); +} + +function keydown( + target: EventTarget, + key: string, + init: KeyboardEventInit = {}, +) { + act(() => { + target.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, ...init }), + ); + }); +} + +beforeEach(() => { + localStorage.clear(); + container = document.createElement('div'); + document.body.appendChild(container); +}); + +afterEach(() => { + unmount(); + container.remove(); +}); + +describe('VisualEditingToggle', () => { + it('starts on by default and reflects it', () => { + const controller = fakeController(); + render(controller); + + expect(controller.enableClickToEdit).toHaveBeenCalledTimes(1); + expect($('.label')?.textContent).toBe('Visual editing on'); + expect($('.switch')?.getAttribute('aria-checked')).toBe('true'); + expect($('.hint')?.textContent).toBe('Hold ⌥ Alt to hide'); + }); + + it('respects defaultEnabled=false', () => { + const controller = fakeController(); + render(controller, { defaultEnabled: false }); + + expect(controller.disableClickToEdit).toHaveBeenCalled(); + expect($('.label')?.textContent).toBe('Visual editing off'); + expect(($('.flash') as HTMLButtonElement).disabled).toBe(true); + }); + + it("prefers the editor's saved choice over the default", () => { + localStorage.setItem('datocms-visual-editing-enabled', 'false'); + const controller = fakeController(); + render(controller, { defaultEnabled: true }); + + expect(controller.enableClickToEdit).not.toHaveBeenCalled(); + expect($('.label')?.textContent).toBe('Visual editing off'); + }); + + it('switch toggles click-to-edit, saves the choice and announces it', () => { + const controller = fakeController(); + render(controller); + expect($('.sr')?.textContent).toBe(''); + + click('.switch'); + + expect(controller.disableClickToEdit).toHaveBeenCalled(); + expect(localStorage.getItem('datocms-visual-editing-enabled')).toBe( + 'false', + ); + expect($('.label')?.textContent).toBe('Visual editing off'); + expect($('.sr')?.textContent).toBe('Visual editing off'); + }); + + it('shows the temporary Alt/Option state without changing the saved choice', () => { + const controller = fakeController(); + render(controller); + + keydown(window, 'Alt'); + act(() => controller.simulateAlt(false)); + + expect($('.label')?.textContent).toBe('Visual editing off'); + expect($('.hint')?.textContent).toBe('Temporary · release ⌥ Alt to revert'); + expect($('.sr')?.textContent).toBe('Visual editing off, temporarily'); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keyup', { key: 'Alt', bubbles: true }), + ); + controller.simulateAlt(true); + }); + + expect($('.hint')?.textContent).toBe('Hold ⌥ Alt to hide'); + expect(localStorage.getItem('datocms-visual-editing-enabled')).toBeNull(); + }); + + it('treats non-Alt changes from app code as the new state', () => { + const controller = fakeController(); + render(controller); + + act(() => controller.simulateAlt(false)); + + expect($('.label')?.textContent).toBe('Visual editing off'); + expect($('.hint')?.textContent).toBe('Hold ⌥ Alt to peek'); + }); + + it('flashes all editable elements', () => { + const controller = fakeController(); + render(controller); + + click('.flash'); + + expect(controller.flashAll).toHaveBeenCalledWith(true); + }); + + it('remembers the collapsed state', () => { + render(fakeController()); + + click('.collapse'); + + expect($('.label')).toBeNull(); + expect($('.switch')).not.toBeNull(); + expect($('.collapse')?.getAttribute('aria-expanded')).toBe('false'); + expect( + localStorage.getItem('datocms-visual-editing-enabled-collapsed'), + ).toBe('true'); + + unmount(); + render(fakeController()); + + expect($('.label')).toBeNull(); + }); + + it('shows "Exit preview" only with exitDraftModeUrl', () => { + render(fakeController()); + expect($('.exit')).toBeNull(); + + unmount(); + render(fakeController(), { exitDraftModeUrl: '/api/draft-mode/disable' }); + + expect($('.exit')?.getAttribute('href')).toBe('/api/draft-mode/disable'); + }); + + it('moves with arrow keys on the grip and resets with Home', () => { + render(fakeController()); + const grip = $('.grip') as HTMLElement; + + keydown(grip, 'ArrowRight', { shiftKey: true }); + + expect($('.widget')?.style.left).toBe('50px'); + expect( + localStorage.getItem('datocms-visual-editing-enabled-position'), + ).not.toBeNull(); + + keydown(grip, 'Home'); + + expect($('.widget')?.style.left).toBe(''); + expect( + localStorage.getItem('datocms-visual-editing-enabled-position'), + ).toBeNull(); + }); + + it('keeps an off-screen saved position inside the viewport', () => { + localStorage.setItem( + 'datocms-visual-editing-enabled-position', + JSON.stringify({ x: 5000, y: 5000 }), + ); + render(fakeController()); + + // jsdom has no layout: the widget measures 0×0 + expect($('.widget')?.style.left).toBe(`${window.innerWidth - 8}px`); + expect($('.widget')?.style.top).toBe(`${window.innerHeight - 8}px`); + // the saved position is kept, for when the window grows again + expect( + localStorage.getItem('datocms-visual-editing-enabled-position'), + ).toBe(JSON.stringify({ x: 5000, y: 5000 })); + }); + + describe('hoverOnly', () => { + const original = window.matchMedia; + + beforeEach(() => { + window.matchMedia = ((query: string) => ({ + matches: query !== '(hover: hover)', + })) as unknown as typeof window.matchMedia; + }); + + afterEach(() => { + window.matchMedia = original; + }); + + it('starts off on touch devices', () => { + const controller = fakeController(); + render(controller, { hoverOnly: true }); + + expect(controller.enableClickToEdit).not.toHaveBeenCalled(); + expect($('.label')?.textContent).toBe('Visual editing off'); + }); + + it("still uses the editor's saved choice", () => { + localStorage.setItem('datocms-visual-editing-enabled', 'true'); + const controller = fakeController(); + render(controller, { hoverOnly: true }); + + expect(controller.enableClickToEdit).toHaveBeenCalled(); + }); + }); + + it('renders nothing and leaves click-to-edit alone inside an iframe', () => { + // Not by overriding window.parent: on Node 18, jsdom doesn't restore it properly + const inIframe = jest.spyOn(utils, 'isInIframe').mockReturnValue(true); + + try { + const controller = fakeController(); + render(controller); + + expect(shadow()).toBeNull(); + expect(controller.enableClickToEdit).not.toHaveBeenCalled(); + expect(controller.disableClickToEdit).not.toHaveBeenCalled(); + } finally { + unmount(); + inIframe.mockRestore(); + } + }); + + it('removes itself on unmount', () => { + render(fakeController()); + expect(shadow()).not.toBeNull(); + + unmount(); + + expect(shadow()).toBeNull(); + }); +}); diff --git a/src/ContentLink/__tests__/index.test.tsx b/src/ContentLink/__tests__/index.test.tsx new file mode 100644 index 0000000..f160ece --- /dev/null +++ b/src/ContentLink/__tests__/index.test.tsx @@ -0,0 +1,87 @@ +import React, { type ReactElement } from 'react'; +import ReactDOM from 'react-dom'; +import { act } from 'react-dom/test-utils'; +import { ContentLink } from '../index.js'; + +const mockController = { + enabled: false, + enableClickToEdit: jest.fn(() => { + mockController.enabled = true; + }), + disableClickToEdit: jest.fn(() => { + mockController.enabled = false; + }), + isClickToEditEnabled: () => mockController.enabled, + flashAll: jest.fn(), + setCurrentPath: jest.fn(), + dispose: jest.fn(), +}; + +jest.mock('@datocms/content-link', () => ({ + createController: jest.fn(() => mockController), +})); + +let container: HTMLDivElement; + +function render(element: ReactElement) { + act(() => { + ReactDOM.render(element, container); + }); +} + +function toolbar() { + return document.querySelector('[data-datocms-visual-editing-toggle]'); +} + +beforeEach(() => { + localStorage.clear(); + mockController.enabled = false; + jest.clearAllMocks(); + container = document.createElement('div'); + document.body.appendChild(container); +}); + +afterEach(() => { + act(() => { + ReactDOM.unmountComponentAtNode(container); + }); + container.remove(); +}); + +describe('ContentLink', () => { + it('renders no toolbar by default', () => { + render(); + + expect(toolbar()).toBeNull(); + expect(mockController.enableClickToEdit).toHaveBeenCalledTimes(1); + }); + + it('renders the toolbar with toggle, starting from enableClickToEdit', () => { + render(); + + expect(toolbar()).not.toBeNull(); + expect(mockController.enableClickToEdit).toHaveBeenCalledTimes(1); + expect(toolbar()?.shadowRoot?.querySelector('.label')?.textContent).toBe( + 'Visual editing on', + ); + }); + + it('starts off with toggle but no enableClickToEdit', () => { + render(); + + expect(mockController.enableClickToEdit).not.toHaveBeenCalled(); + expect(toolbar()?.shadowRoot?.querySelector('.label')?.textContent).toBe( + 'Visual editing off', + ); + }); + + it('passes toggle options through', () => { + render( + , + ); + + expect( + toolbar()?.shadowRoot?.querySelector('.exit')?.getAttribute('href'), + ).toBe('/api/draft-mode/disable'); + }); +}); diff --git a/src/ContentLink/index.tsx b/src/ContentLink/index.tsx index 7375bfa..04ec72a 100644 --- a/src/ContentLink/index.tsx +++ b/src/ContentLink/index.tsx @@ -1,11 +1,17 @@ 'use client'; -import { useEffect } from 'react'; +import React, { type ReactElement, useEffect } from 'react'; import { type ClickToEditOptions, type UseContentLinkOptions, useContentLink, } from '../useContentLink/index.js'; +import { + VisualEditingToggle, + type VisualEditingToggleOptions, +} from './VisualEditingToggle/index.js'; + +export * from './VisualEditingToggle/index.js'; export type ContentLinkProps = Omit & { /** Current pathname to sync with Web Previews plugin */ @@ -33,6 +39,19 @@ export type ContentLinkProps = Omit & { * @default 17 (orange) */ hue?: number; + /** + * Show a floating toolbar to turn click-to-edit on/off (only render `` + * in draft mode). Pass `true`, or options such as `exitDraftModeUrl`. + * + * With the toolbar, `enableClickToEdit` becomes the starting state: the editor's + * own choice is remembered (localStorage) and wins on later visits. + * + * @example + * ```tsx + * + * ``` + */ + toggle?: boolean | VisualEditingToggleOptions; }; /** @@ -97,18 +116,19 @@ export type ContentLinkProps = Omit & { * 4. **Click to edit**: Click on any content to open the DatoCMS editor at that field. * * @param props - Configuration props for ContentLink - * @returns null - This component has no visual output + * @returns The Visual Editing toolbar when `toggle` is set, otherwise null */ -export function ContentLink(props: ContentLinkProps): null { +export function ContentLink(props: ContentLinkProps): ReactElement | null { const { currentPath, enableClickToEdit: enableClickToEditOptions, stripStega, hue, + toggle, ...useContentLinkOptions } = props; - const { enableClickToEdit, setCurrentPath } = useContentLink({ + const { controller, enableClickToEdit, setCurrentPath } = useContentLink({ ...useContentLinkOptions, enabled: stripStega !== undefined ? { stripStega } : true, hue, @@ -121,9 +141,9 @@ export function ContentLink(props: ContentLinkProps): null { } }, [currentPath, setCurrentPath]); - // Enable click-to-edit on mount if requested + // Enable click-to-edit on mount if requested (the toolbar does it itself) useEffect(() => { - if (!enableClickToEditOptions) { + if (!enableClickToEditOptions || toggle) { return; } @@ -135,7 +155,25 @@ export function ContentLink(props: ContentLinkProps): null { enableClickToEdit( enableClickToEditOptions === true ? undefined : enableClickToEditOptions, ); - }, [enableClickToEditOptions, enableClickToEdit]); + }, [enableClickToEditOptions, enableClickToEdit, toggle]); + + if (!toggle || !controller) { + return null; + } + + const clickToEditOptions = + typeof enableClickToEditOptions === 'object' + ? enableClickToEditOptions + : {}; - return null; + return ( + + ); } diff --git a/src/useContentLink/index.ts b/src/useContentLink/index.ts index 4477654..a752534 100644 --- a/src/useContentLink/index.ts +++ b/src/useContentLink/index.ts @@ -1,7 +1,13 @@ 'use client'; import { type Controller, createController } from '@datocms/content-link'; -import { type RefObject, useCallback, useEffect, useRef } from 'react'; +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; export type UseContentLinkOptions = { /** @@ -103,6 +109,9 @@ export function useContentLink( const { enabled = true, onNavigateTo, root, hue } = options; const controllerRef = useRef(null); + // Mirrors controllerRef so that components re-render once the controller exists + // (it's created in an effect, after the first render). + const [controller, setController] = useState(null); // Store the callback in a ref to avoid recreating the controller when it changes const onNavigateToRef = useRef(onNavigateTo); @@ -123,6 +132,7 @@ export function useContentLink( controllerRef.current.dispose(); controllerRef.current = null; } + setController(null); return; } @@ -137,6 +147,7 @@ export function useContentLink( }); controllerRef.current = controller; + setController(controller); return () => { controller.dispose(); @@ -179,7 +190,7 @@ export function useContentLink( }, []); return { - controller: controllerRef.current, + controller, enableClickToEdit, disableClickToEdit, isClickToEditEnabled,