From f2f941a0339ba1f2d0400fe2c507b986c34b0290 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sat, 21 Mar 2026 23:28:51 -0700 Subject: [PATCH 1/9] feat: add opt+drag to clone array items in CMS doc editor --- .changeset/opt-drag-clone-array-items.md | 5 ++ .../ui/components/DocEditor/DocEditor.tsx | 87 +++++++++++++++++-- 2 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 .changeset/opt-drag-clone-array-items.md diff --git a/.changeset/opt-drag-clone-array-items.md b/.changeset/opt-drag-clone-array-items.md new file mode 100644 index 000000000..17597eac6 --- /dev/null +++ b/.changeset/opt-drag-clone-array-items.md @@ -0,0 +1,5 @@ +--- +"@blinkk/root-cms": patch +--- + +Add opt+drag to clone array items in the CMS doc editor. diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 65b9073b0..fce15dbdc 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -784,6 +784,14 @@ interface ArrayMoveTo { deepKey: string; } +interface ArrayCloneTo { + type: 'cloneTo'; + fromIndex: number; + toIndex: number; + draft: DraftDocController; + deepKey: string; +} + interface ArrayRemoveAt { type: 'removeAt'; index: number; @@ -809,6 +817,7 @@ interface ArrayPasteBefore { type ArrayAction = | ArrayAdd + | ArrayCloneTo | ArrayDuplicate | ArrayInsertAfter | ArrayInsertBefore @@ -964,6 +973,40 @@ function arrayReducer(state: ArrayFieldValue, action: ArrayAction) { _moved: itemKey, }; } + case 'cloneTo': { + const data = state ?? {}; + const order = [...(data._array || [])]; + if ( + action.fromIndex < 0 || + action.fromIndex >= order.length || + action.toIndex < 0 || + action.toIndex >= order.length + ) { + console.error('Invalid cloneTo index', action); + return state; + } + const sourceKey = order[action.fromIndex]; + const clonedValue = structuredClone(data[sourceKey] || {}); + const newKey = autokey(); + // Adjust insert index: the drag library reports toIndex assuming the + // source was removed. Since we keep the source, shift by 1 when + // dragging downward. + const insertIndex = + action.fromIndex < action.toIndex + ? action.toIndex + 1 + : action.toIndex; + order.splice(insertIndex, 0, newKey); + action.draft.updateKeys({ + [`${action.deepKey}._array`]: order, + [`${action.deepKey}.${newKey}`]: clonedValue, + }); + return { + ...data, + [newKey]: clonedValue, + _array: order, + _pasted: newKey, + }; + } case 'removeAt': { const data = {...(state ?? {})}; const order = data._array || []; @@ -1050,6 +1093,7 @@ DocEditor.ArrayField = (props: FieldProps) => { const deeplink = useDeeplink(); const virtualClipboard = useVirtualClipboard(); const experiments = window.__ROOT_CTX.experiments || {}; + const altKeyRef = useRef(false); const data = value ?? {}; const order = data._array || []; @@ -1058,6 +1102,25 @@ DocEditor.ArrayField = (props: FieldProps) => { const newlyAdded = value._new || []; const [cutIndex, setCutIndex] = useState(null); + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Alt') { + altKeyRef.current = true; + } + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Alt') { + altKeyRef.current = false; + } + }; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + }; + }, []); + useDraftDocField(props.deepKey, (newValue: ArrayFieldValue) => { dispatch({type: 'update', newValue}); }); @@ -1353,13 +1416,23 @@ DocEditor.ArrayField = (props: FieldProps) => { if (!destination) { return; } - dispatch({ - type: 'moveTo', - fromIndex: source.index, - toIndex: destination.index, - draft, - deepKey: props.deepKey, - }); + if (altKeyRef.current) { + dispatch({ + type: 'cloneTo', + fromIndex: source.index, + toIndex: destination.index, + draft, + deepKey: props.deepKey, + }); + } else { + dispatch({ + type: 'moveTo', + fromIndex: source.index, + toIndex: destination.index, + draft, + deepKey: props.deepKey, + }); + } }} > From 5b20e9097bf80cfdeece76fe28a747eb16be9ad3 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sat, 21 Mar 2026 23:44:20 -0700 Subject: [PATCH 2/9] fix: track alt key via both mousemove and keydown/keyup during drag --- docs/scripts/env-init.sh | 13 +++- packages/root-cms/signin/signin.tsx | 29 +++++-- packages/root-cms/signin/styles/global.css | 12 +++ .../ui/components/DocEditor/DocEditor.tsx | 16 +++- packages/root-cms/ui/styles/global.css | 12 +++ packages/root-cms/ui/ui.tsx | 76 ++++++++++++------- 6 files changed, 119 insertions(+), 39 deletions(-) diff --git a/docs/scripts/env-init.sh b/docs/scripts/env-init.sh index c116b0d99..ca510385b 100755 --- a/docs/scripts/env-init.sh +++ b/docs/scripts/env-init.sh @@ -10,10 +10,19 @@ ENV_FILE="$DOCS_DIR/.env" PROJECT="rootjs-dev" SECRET_NAME="docs-env" -if [[ -f "$ENV_FILE" ]]; then +if [[ -s "$ENV_FILE" ]]; then exit 0 fi echo ".env file not found, downloading from Google Secrets Manager..." -gcloud secrets versions access latest --secret="$SECRET_NAME" --project="$PROJECT" > "$ENV_FILE" +ENV_CONTENT="$(gcloud secrets versions access latest --secret="$SECRET_NAME" --project="$PROJECT")" || { + echo "ERROR: Failed to download .env from Google Secrets Manager." >&2 + echo "Ensure gcloud is installed, you are authenticated, and have access to project '$PROJECT'." >&2 + exit 1 +} +if [[ -z "$ENV_CONTENT" ]]; then + echo "ERROR: Secret '$SECRET_NAME' returned empty content." >&2 + exit 1 +fi +echo "$ENV_CONTENT" > "$ENV_FILE" echo "Downloaded .env file." diff --git a/packages/root-cms/signin/signin.tsx b/packages/root-cms/signin/signin.tsx index f25c870dc..dc9d88f6e 100644 --- a/packages/root-cms/signin/signin.tsx +++ b/packages/root-cms/signin/signin.tsx @@ -153,9 +153,26 @@ function loginSuccessRedirect() { window.location.replace(redirectUrl); } -const app = initializeApp(window.__ROOT_CTX.firebaseConfig); -const auth = getAuth(app); -window.firebase = {app, auth}; -const root = document.getElementById('root')!; -root.innerHTML = ''; -render(, root); +try { + const app = initializeApp(window.__ROOT_CTX.firebaseConfig); + const auth = getAuth(app); + window.firebase = {app, auth}; + const root = document.getElementById('root')!; + root.innerHTML = ''; + render(, root); +} catch (err) { + console.error(err); + const root = document.getElementById('root')!; + root.innerHTML = ''; + render(, root); +} + +function BootstrapError(props: {message: string}) { + return ( +
+
+ Something went wrong: {props.message} +
+
+ ); +} diff --git a/packages/root-cms/signin/styles/global.css b/packages/root-cms/signin/styles/global.css index 126be471d..905bdbafa 100644 --- a/packages/root-cms/signin/styles/global.css +++ b/packages/root-cms/signin/styles/global.css @@ -102,6 +102,18 @@ body.menu\:open { padding: 0 24px; } +.bootstrap-warning { + max-width: 520px; + padding: 12px; + border: 1px solid #fbbc04; + border-radius: 8px; + background: #fff9db; + color: #5f370e; + font-size: 14px; + line-height: 1.5; + font-weight: 400; +} + @keyframes bootstrap-loading-error { 0% { opacity: 0; diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index fce15dbdc..f173e0390 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -1102,7 +1102,13 @@ DocEditor.ArrayField = (props: FieldProps) => { const newlyAdded = value._new || []; const [cutIndex, setCutIndex] = useState(null); + // Track the Alt/Option key state for opt+drag cloning. The drag library + // blocks drag initiation when modifier keys are held, so the user must + // start dragging first, then hold Alt/Option to clone on drop. useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + altKeyRef.current = e.altKey; + }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Alt') { altKeyRef.current = true; @@ -1113,11 +1119,13 @@ DocEditor.ArrayField = (props: FieldProps) => { altKeyRef.current = false; } }; - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('keyup', onKeyUp, true); return () => { - window.removeEventListener('keydown', onKeyDown); - window.removeEventListener('keyup', onKeyUp); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('keyup', onKeyUp, true); }; }, []); diff --git a/packages/root-cms/ui/styles/global.css b/packages/root-cms/ui/styles/global.css index 6cdc29f9d..6de0f8e1f 100644 --- a/packages/root-cms/ui/styles/global.css +++ b/packages/root-cms/ui/styles/global.css @@ -108,6 +108,18 @@ body.menu\:open { padding: 0 24px; } +.bootstrap-warning { + max-width: 520px; + padding: 12px; + border: 1px solid #fbbc04; + border-radius: 8px; + background: #fff9db; + color: #5f370e; + font-size: 14px; + line-height: 1.5; + font-weight: 400; +} + .sr-only { position: absolute; width: 1px; diff --git a/packages/root-cms/ui/ui.tsx b/packages/root-cms/ui/ui.tsx index a2e5439d3..49fc909fc 100644 --- a/packages/root-cms/ui/ui.tsx +++ b/packages/root-cms/ui/ui.tsx @@ -176,7 +176,12 @@ declare global { channel: true | false | 'to-preview' | 'from-preview'; }; /** Checks registered via the CMS plugin config. */ - checks?: Array<{id: string; label: string; description?: string; collections?: string[]}>; + checks?: Array<{ + id: string; + label: string; + description?: string; + collections?: string[]; + }>; }; firebase: FirebaseContextObject; } @@ -316,35 +321,52 @@ function registerDevServerRedirectShortcut() { registerDevServerRedirectShortcut(); -const app = initializeApp(window.__ROOT_CTX.firebaseConfig); -const databaseId = window.__ROOT_CTX.firebaseConfig.databaseId || '(default)'; -// const db = getFirestore(app); -// NOTE(stevenle): the firestore web channel rpc sometimes has issues in -// collections with a large number of docs. Forcing long polling and disabling -// fetch streams seems to work for some people. This may cause performance -// issues however. -const db = initializeFirestore( - app, - { - experimentalForceLongPolling: true, - useFetchStreams: false, - } as any, - databaseId -); -const auth = getAuth(app); -const storage = getStorage(app); -auth.onAuthStateChanged((user) => { - if (!user) { - loginRedirect(); - return; - } - window.firebase = {app, auth, db, storage, user}; +try { + const app = initializeApp(window.__ROOT_CTX.firebaseConfig); + const databaseId = window.__ROOT_CTX.firebaseConfig.databaseId || '(default)'; + // const db = getFirestore(app); + // NOTE(stevenle): the firestore web channel rpc sometimes has issues in + // collections with a large number of docs. Forcing long polling and disabling + // fetch streams seems to work for some people. This may cause performance + // issues however. + const db = initializeFirestore( + app, + { + experimentalForceLongPolling: true, + useFetchStreams: false, + } as any, + databaseId + ); + const auth = getAuth(app); + const storage = getStorage(app); + auth.onAuthStateChanged((user) => { + if (!user) { + loginRedirect(); + return; + } + window.firebase = {app, auth, db, storage, user}; + const root = document.getElementById('root')!; + root.innerHTML = ''; + render(, root); + + updateSession(user); + }); +} catch (err) { + console.error(err); const root = document.getElementById('root')!; root.innerHTML = ''; - render(, root); + render(, root); +} - updateSession(user); -}); +function BootstrapError(props: {message: string}) { + return ( +
+
+ Something went wrong: {props.message} +
+
+ ); +} async function updateSession(user: User) { const idToken = await user.getIdToken(); From 5fd4adf13d8ff40b527e1c4eedd6f1d681eaa1f8 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sat, 21 Mar 2026 23:52:12 -0700 Subject: [PATCH 3/9] fix: monkeypatch library to allow Alt+mousedown drag initiation --- .../ui/components/DocEditor/DocEditor.tsx | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index f173e0390..36f6917ff 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -7,6 +7,29 @@ import { DroppableProvided, DropResult, } from '@hello-pangea/dnd'; + +// Monkeypatch: the @hello-pangea/dnd mouse sensor rejects mousedown events when +// modifier keys (including Alt/Option) are held. To support Alt+drag for cloning +// array items, we intercept mousedown at the capture phase *before* the library +// registers its own listener, and override `altKey` on the event instance. +let _cloneDragActive = false; +if (typeof window !== 'undefined') { + window.addEventListener( + 'mousedown', + (e: MouseEvent) => { + if (!e.altKey) { + _cloneDragActive = false; + return; + } + const target = e.target as Element | null; + if (target?.closest?.('.DocEditor__ArrayField__item__handle')) { + _cloneDragActive = true; + Object.defineProperty(e, 'altKey', {value: false}); + } + }, + {capture: true} + ); +} import { ActionIcon, Button, @@ -1093,7 +1116,6 @@ DocEditor.ArrayField = (props: FieldProps) => { const deeplink = useDeeplink(); const virtualClipboard = useVirtualClipboard(); const experiments = window.__ROOT_CTX.experiments || {}; - const altKeyRef = useRef(false); const data = value ?? {}; const order = data._array || []; @@ -1102,33 +1124,6 @@ DocEditor.ArrayField = (props: FieldProps) => { const newlyAdded = value._new || []; const [cutIndex, setCutIndex] = useState(null); - // Track the Alt/Option key state for opt+drag cloning. The drag library - // blocks drag initiation when modifier keys are held, so the user must - // start dragging first, then hold Alt/Option to clone on drop. - useEffect(() => { - const onMouseMove = (e: MouseEvent) => { - altKeyRef.current = e.altKey; - }; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Alt') { - altKeyRef.current = true; - } - }; - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Alt') { - altKeyRef.current = false; - } - }; - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('keydown', onKeyDown, true); - window.addEventListener('keyup', onKeyUp, true); - return () => { - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('keydown', onKeyDown, true); - window.removeEventListener('keyup', onKeyUp, true); - }; - }, []); - useDraftDocField(props.deepKey, (newValue: ArrayFieldValue) => { dispatch({type: 'update', newValue}); }); @@ -1424,7 +1419,8 @@ DocEditor.ArrayField = (props: FieldProps) => { if (!destination) { return; } - if (altKeyRef.current) { + if (_cloneDragActive) { + _cloneDragActive = false; dispatch({ type: 'cloneTo', fromIndex: source.index, From ea231317be8e2c46e0df64b573f543cd467218d0 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sat, 21 Mar 2026 23:56:38 -0700 Subject: [PATCH 4/9] Show visual clone during alt+drag --- .../ui/components/DocEditor/DocEditor.tsx | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 36f6917ff..ad915244e 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -1450,17 +1450,18 @@ DocEditor.ArrayField = (props: FieldProps) => { return ( {(provided, snapshot) => ( -
+ <> +
{
+ {snapshot.isDragging && _cloneDragActive && ( +
+
+ +
+
+ +
+ +
+ +
+
+
+ )} + )} ); From 242b81f044961151aa657f0ff8b8d53e8f574311 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sun, 22 Mar 2026 00:02:35 -0700 Subject: [PATCH 5/9] Use renderClone on Droppable so original item stays in flow during drag --- .../ui/components/DocEditor/DocEditor.tsx | 60 +++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index ad915244e..1a89dcccd 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -1439,7 +1439,41 @@ DocEditor.ArrayField = (props: FieldProps) => { } }} > - + { + const dragIndex = rubric.source.index; + const dragKey = order[dragIndex]; + return ( +
+
+ +
+
+ +
+ +
+ +
+
+
+ ); + }} + > {(provided: DroppableProvided) => (
{ return ( {(provided, snapshot) => ( - <>
{
- {snapshot.isDragging && _cloneDragActive && ( -
-
- -
-
- -
- -
- -
-
-
- )} - )} ); From 8ebaafc753a23008eabee86ffb95bd2e4e737cea Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sun, 22 Mar 2026 08:44:19 -0700 Subject: [PATCH 6/9] Clone item before drag starts so original and copy are visible immediately --- .../ui/components/DocEditor/DocEditor.tsx | 126 +++++++++--------- 1 file changed, 61 insertions(+), 65 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 1a89dcccd..c766fb757 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -12,20 +12,60 @@ import { // modifier keys (including Alt/Option) are held. To support Alt+drag for cloning // array items, we intercept mousedown at the capture phase *before* the library // registers its own listener, and override `altKey` on the event instance. +// +// Flow: Alt+mousedown → clone item in state → stopPropagation → after re-render, +// dispatch a synthetic mousedown so the library starts a normal drag on the +// original item (which now sits next to its clone). let _cloneDragActive = false; +let _syntheticMousedown = false; +let _cloneFn: ((index: number) => void) | null = null; if (typeof window !== 'undefined') { window.addEventListener( 'mousedown', (e: MouseEvent) => { + if (_syntheticMousedown) { + _syntheticMousedown = false; + return; + } if (!e.altKey) { _cloneDragActive = false; return; } const target = e.target as Element | null; - if (target?.closest?.('.DocEditor__ArrayField__item__handle')) { - _cloneDragActive = true; - Object.defineProperty(e, 'altKey', {value: false}); - } + const handle = target?.closest?.('.DocEditor__ArrayField__item__handle') as HTMLElement | null; + if (!handle) return; + + _cloneDragActive = true; + e.stopPropagation(); + + // Determine which item index was clicked. + const wrapper = handle.closest('.DocEditor__ArrayField__item__wrapper'); + const container = wrapper?.parentElement; + if (!wrapper || !container) return; + const siblings = container.querySelectorAll( + ':scope > .DocEditor__ArrayField__item__wrapper' + ); + const index = Array.from(siblings).indexOf(wrapper); + if (index < 0 || !_cloneFn) return; + + // Clone the item in state (inserts duplicate at index + 1). + _cloneFn(index); + + // After Preact re-renders, re-dispatch a plain mousedown so the library + // picks up a normal drag on the original item. + const {clientX, clientY, button} = e; + requestAnimationFrame(() => { + _syntheticMousedown = true; + handle.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX, + clientY, + button, + }) + ); + }); }, {capture: true} ); @@ -807,14 +847,6 @@ interface ArrayMoveTo { deepKey: string; } -interface ArrayCloneTo { - type: 'cloneTo'; - fromIndex: number; - toIndex: number; - draft: DraftDocController; - deepKey: string; -} - interface ArrayRemoveAt { type: 'removeAt'; index: number; @@ -840,7 +872,6 @@ interface ArrayPasteBefore { type ArrayAction = | ArrayAdd - | ArrayCloneTo | ArrayDuplicate | ArrayInsertAfter | ArrayInsertBefore @@ -996,40 +1027,6 @@ function arrayReducer(state: ArrayFieldValue, action: ArrayAction) { _moved: itemKey, }; } - case 'cloneTo': { - const data = state ?? {}; - const order = [...(data._array || [])]; - if ( - action.fromIndex < 0 || - action.fromIndex >= order.length || - action.toIndex < 0 || - action.toIndex >= order.length - ) { - console.error('Invalid cloneTo index', action); - return state; - } - const sourceKey = order[action.fromIndex]; - const clonedValue = structuredClone(data[sourceKey] || {}); - const newKey = autokey(); - // Adjust insert index: the drag library reports toIndex assuming the - // source was removed. Since we keep the source, shift by 1 when - // dragging downward. - const insertIndex = - action.fromIndex < action.toIndex - ? action.toIndex + 1 - : action.toIndex; - order.splice(insertIndex, 0, newKey); - action.draft.updateKeys({ - [`${action.deepKey}._array`]: order, - [`${action.deepKey}.${newKey}`]: clonedValue, - }); - return { - ...data, - [newKey]: clonedValue, - _array: order, - _pasted: newKey, - }; - } case 'removeAt': { const data = {...(state ?? {})}; const order = data._array || []; @@ -1203,6 +1200,15 @@ DocEditor.ArrayField = (props: FieldProps) => { }); }; + // Register the clone callback so the module-level mousedown handler can + // duplicate an item before the drag starts. + useEffect(() => { + _cloneFn = (index: number) => duplicate(index); + return () => { + if (_cloneFn) _cloneFn = null; + }; + }); + const removeAt = (index: number) => { dispatch({ type: 'removeAt', @@ -1415,28 +1421,18 @@ DocEditor.ArrayField = (props: FieldProps) => {
{ + _cloneDragActive = false; const {source, destination} = result; if (!destination) { return; } - if (_cloneDragActive) { - _cloneDragActive = false; - dispatch({ - type: 'cloneTo', - fromIndex: source.index, - toIndex: destination.index, - draft, - deepKey: props.deepKey, - }); - } else { - dispatch({ - type: 'moveTo', - fromIndex: source.index, - toIndex: destination.index, - draft, - deepKey: props.deepKey, - }); - } + dispatch({ + type: 'moveTo', + fromIndex: source.index, + toIndex: destination.index, + draft, + deepKey: props.deepKey, + }); }} > Date: Sun, 22 Mar 2026 08:46:52 -0700 Subject: [PATCH 7/9] Simplify to option+click on handle to clone item --- .../ui/components/DocEditor/DocEditor.tsx | 117 ++---------------- 1 file changed, 8 insertions(+), 109 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index c766fb757..91c8a1530 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -7,69 +7,6 @@ import { DroppableProvided, DropResult, } from '@hello-pangea/dnd'; - -// Monkeypatch: the @hello-pangea/dnd mouse sensor rejects mousedown events when -// modifier keys (including Alt/Option) are held. To support Alt+drag for cloning -// array items, we intercept mousedown at the capture phase *before* the library -// registers its own listener, and override `altKey` on the event instance. -// -// Flow: Alt+mousedown → clone item in state → stopPropagation → after re-render, -// dispatch a synthetic mousedown so the library starts a normal drag on the -// original item (which now sits next to its clone). -let _cloneDragActive = false; -let _syntheticMousedown = false; -let _cloneFn: ((index: number) => void) | null = null; -if (typeof window !== 'undefined') { - window.addEventListener( - 'mousedown', - (e: MouseEvent) => { - if (_syntheticMousedown) { - _syntheticMousedown = false; - return; - } - if (!e.altKey) { - _cloneDragActive = false; - return; - } - const target = e.target as Element | null; - const handle = target?.closest?.('.DocEditor__ArrayField__item__handle') as HTMLElement | null; - if (!handle) return; - - _cloneDragActive = true; - e.stopPropagation(); - - // Determine which item index was clicked. - const wrapper = handle.closest('.DocEditor__ArrayField__item__wrapper'); - const container = wrapper?.parentElement; - if (!wrapper || !container) return; - const siblings = container.querySelectorAll( - ':scope > .DocEditor__ArrayField__item__wrapper' - ); - const index = Array.from(siblings).indexOf(wrapper); - if (index < 0 || !_cloneFn) return; - - // Clone the item in state (inserts duplicate at index + 1). - _cloneFn(index); - - // After Preact re-renders, re-dispatch a plain mousedown so the library - // picks up a normal drag on the original item. - const {clientX, clientY, button} = e; - requestAnimationFrame(() => { - _syntheticMousedown = true; - handle.dispatchEvent( - new MouseEvent('mousedown', { - bubbles: true, - cancelable: true, - clientX, - clientY, - button, - }) - ); - }); - }, - {capture: true} - ); -} import { ActionIcon, Button, @@ -1200,15 +1137,6 @@ DocEditor.ArrayField = (props: FieldProps) => { }); }; - // Register the clone callback so the module-level mousedown handler can - // duplicate an item before the drag starts. - useEffect(() => { - _cloneFn = (index: number) => duplicate(index); - return () => { - if (_cloneFn) _cloneFn = null; - }; - }); - const removeAt = (index: number) => { dispatch({ type: 'removeAt', @@ -1421,7 +1349,6 @@ DocEditor.ArrayField = (props: FieldProps) => {
{ - _cloneDragActive = false; const {source, destination} = result; if (!destination) { return; @@ -1435,41 +1362,7 @@ DocEditor.ArrayField = (props: FieldProps) => { }); }} > - { - const dragIndex = rubric.source.index; - const dragKey = order[dragIndex]; - return ( -
-
- -
-
- -
- -
- -
-
-
- ); - }} - > + {(provided: DroppableProvided) => (
{
focusFieldHeader(props.deepKey, i)} + onClick={(e: MouseEvent) => { + if (e.altKey) { + duplicate(i); + return; + } + focusFieldHeader(props.deepKey, i); + }} >
From 1c82d5d3aee8fb84cf55a028492c7d4b97b3c009 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sun, 22 Mar 2026 08:49:20 -0700 Subject: [PATCH 8/9] Keep cloned item collapsed when using option+click --- .../root-cms/ui/components/DocEditor/DocEditor.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 91c8a1530..e49d2956c 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -760,6 +760,7 @@ interface ArrayDuplicate { draft: DraftDocController; deepKey: string; value: ArrayItemValue; + skipOpen?: boolean; } interface ArrayMoveUp { @@ -906,7 +907,7 @@ function arrayReducer(state: ArrayFieldValue, action: ArrayAction) { ...data, [newKey]: clonedValue, _array: order, - _new: [...newlyAdded, newKey], + _new: action.skipOpen ? newlyAdded : [...newlyAdded, newKey], }; } case 'moveUp': { @@ -1389,7 +1390,14 @@ DocEditor.ArrayField = (props: FieldProps) => { {...provided.dragHandleProps} onClick={(e: MouseEvent) => { if (e.altKey) { - duplicate(i); + dispatch({ + type: 'duplicate', + draft, + deepKey: props.deepKey, + index: i, + value: getItemValue(i), + skipOpen: true, + }); return; } focusFieldHeader(props.deepKey, i); From 5729267fd6c3917b517a3cb4f58115c94c8f7b5d Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sun, 22 Mar 2026 08:51:41 -0700 Subject: [PATCH 9/9] Focus cloned item after option+click duplicate --- packages/root-cms/ui/components/DocEditor/DocEditor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index e49d2956c..33c2b41ba 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -908,6 +908,7 @@ function arrayReducer(state: ArrayFieldValue, action: ArrayAction) { [newKey]: clonedValue, _array: order, _new: action.skipOpen ? newlyAdded : [...newlyAdded, newKey], + _moved: action.skipOpen ? newKey : undefined, }; } case 'moveUp': {