Skip to content

Commit 0709277

Browse files
fix(admin): prevent URL editing of unsupported blocks (#4314)
* fix(admin): prevent URL editing of unsupported blocks Implement the report's minimum safety fix: unregistered blocks without an existing string id/url, including WordPress buttons, no longer offer a URL editor that creates stray parent IDs. Show a localized preservation notice, retain deletion and URL-bearing import editing, and notify node views when registry definitions arrive after mount. Added regression coverage and a patch changeset. Verified 34 focused Chromium tests, admin typecheck, zero quick-lint diagnostics, formatting, root build with increased Node heap, and before/after plus Arabic RTL screenshots. Independent follow-up review found no remaining defects. Full Buttons editing/insertion remains outside this PR. EmDash-Bot-Case: 1198246700-issue-5950893317 EmDash-Bot-Candidate: cand-1-025bf2c08a * fix(admin): prevent URL editing of unsupported blocks Address review of #4314: recognize surviving non-empty node IDs after HTML paste so imported embed/file URL controls remain available, while identity-less pasted Buttons stay protected. Hoist the empty pluginBlocks default to avoid redundant registry broadcasts. Added real HTML clipboard regressions and existing stray-ID compatibility coverage. Documented that prior stray IDs retain controls and that the pre-existing clipboard url-to-id serialization bug is outside this PR. Verified 38 focused Chromium tests, admin typecheck, quick lint (zero diagnostics), formatting and root build with an increased heap. Independent follow-up review found no new defects. EmDash-Bot-Case: 1198246700-issue-5950893317 EmDash-Bot-Candidate: cand-2-b528e3510a --------- Co-authored-by: emdash-autofix[bot] <emdash-autofix[bot]@users.noreply.github.com>
1 parent 046ec3c commit 0709277

5 files changed

Lines changed: 330 additions & 41 deletions

File tree

‎.changeset/sweet-bears-refuse.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@emdash-cms/admin": patch
3+
---
4+
5+
Fixes the admin editor offering URL editing for unregistered blocks without an existing `id` or `url`, including WordPress-imported buttons. These blocks now show an unavailable-editor notice instead of saving an unrelated URL onto the block. Imported embeds and files with existing URLs remain editable. This does not add a Buttons editor or remove previously saved stray URLs. Blocks that already contain a stray `id` still offer URL editing; removing that `id` from the stored Portable Text block disables those controls.

‎packages/admin/src/components/PortableTextEditor.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ import {
203203
registerPluginBlocks,
204204
resolveIcon,
205205
} from "./editor/PluginBlockNode";
206+
import { PORTABLE_TEXT_BLOCK_ATTR } from "./editor/portableTextIdentity.js";
206207
import { runTableAction } from "./editor/TableActions.js";
207208
import { createTableCellSafety, type TablePasteRejection } from "./editor/TableCellSafety.js";
208209
import { createTableClipboard } from "./editor/TableClipboard.js";
@@ -407,7 +408,6 @@ const attrStr = (v: unknown): string | undefined => (typeof v === "string" && v
407408
const attrNum = (v: unknown): number | undefined =>
408409
typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
409410

410-
const PORTABLE_TEXT_BLOCK_ATTR = "emdashPortableTextBlock";
411411
const PORTABLE_TEXT_KEY_ATTR = "emdashPortableTextKey";
412412
const PORTABLE_TEXT_SPAN_MARK = "emdashPortableTextSpan";
413413

@@ -3467,6 +3467,8 @@ function mediaItemToImageAttrs(item: MediaItem) {
34673467
};
34683468
}
34693469

3470+
const EMPTY_PLUGIN_BLOCKS: PluginBlockDef[] = [];
3471+
34703472
/**
34713473
* Portable Text Editor Component
34723474
*/
@@ -3477,7 +3479,7 @@ export function PortableTextEditor({
34773479
className,
34783480
editable = true,
34793481
"aria-labelledby": ariaLabelledby,
3480-
pluginBlocks = [],
3482+
pluginBlocks = EMPTY_PLUGIN_BLOCKS,
34813483
focusMode: controlledFocusMode,
34823484
onEditorReady,
34833485
minimal = false,

‎packages/admin/src/components/editor/PluginBlockNode.tsx‎

Lines changed: 67 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
3333
import * as React from "react";
3434

3535
import { cn } from "../../lib/utils";
36+
import { PORTABLE_TEXT_BLOCK_ATTR } from "./portableTextIdentity.js";
37+
38+
const EMPTY_REGISTRY = new Map<string, PluginBlockDef>();
3639

3740
/**
3841
* Plugin block definition for slash commands
@@ -68,6 +71,8 @@ export function registerPluginBlocks(
6871
const storage = editor.storage.pluginBlock;
6972
if (storage) {
7073
storage.registry = registry;
74+
const listeners = storage.registryListeners as Set<() => void> | undefined;
75+
listeners?.forEach((listener) => listener());
7176
}
7277
}
7378

@@ -76,7 +81,7 @@ function getRegistry(editor: {
7681
storage: Record<string, Record<string, unknown>>;
7782
}): Map<string, PluginBlockDef> {
7883
const storage = editor.storage.pluginBlock;
79-
return (storage?.registry as Map<string, PluginBlockDef>) ?? new Map();
84+
return (storage?.registry as Map<string, PluginBlockDef>) ?? EMPTY_REGISTRY;
8085
}
8186

8287
/** Named icon map: icon key → React component */
@@ -195,9 +200,21 @@ function PluginBlockNodeView({
195200
typeof node.attrs.data === "object" && node.attrs.data !== null
196201
? (node.attrs.data as Record<string, unknown>)
197202
: {};
198-
const registry = getRegistry(
199-
editor as unknown as { storage: Record<string, Record<string, unknown>> },
203+
const registryEditor = editor as unknown as { storage: Record<string, Record<string, unknown>> };
204+
const subscribe = React.useCallback(
205+
(listener: () => void) => {
206+
const listeners = registryEditor.storage.pluginBlock?.registryListeners as
207+
| Set<() => void>
208+
| undefined;
209+
listeners?.add(listener);
210+
return () => {
211+
listeners?.delete(listener);
212+
};
213+
},
214+
[registryEditor],
200215
);
216+
const getSnapshot = React.useCallback(() => getRegistry(registryEditor), [registryEditor]);
217+
const registry = React.useSyncExternalStore(subscribe, getSnapshot);
201218
const { Icon, label, color, placeholder, placeholderFallback } = getEmbedMeta(
202219
blockType,
203220
registry,
@@ -206,6 +223,10 @@ function PluginBlockNodeView({
206223
// Check if this block type has fields defined in the registry
207224
const blockDef = registry.get(blockType);
208225
const hasFields = blockDef?.fields && blockDef.fields.length > 0;
226+
const original = node.attrs[PORTABLE_TEXT_BLOCK_ATTR] as Record<string, unknown> | null;
227+
const hasIdentity = !!id || typeof original?.id === "string" || typeof original?.url === "string";
228+
const canEditUrl = !hasFields && (!!blockDef || hasIdentity);
229+
const canEdit = !!blockDef || canEditUrl;
209230

210231
const [isEditing, setIsEditing] = React.useState(false);
211232
const [editValue, setEditValue] = React.useState(id || "");
@@ -220,7 +241,7 @@ function PluginBlockNodeView({
220241
}, [isEditing, id]);
221242

222243
const handleSave = () => {
223-
if (editValue.trim()) {
244+
if (canEditUrl && editValue.trim()) {
224245
updateAttributes({ id: editValue.trim() });
225246
}
226247
setIsEditing(false);
@@ -284,11 +305,15 @@ function PluginBlockNodeView({
284305
{/* Label and ID */}
285306
<div className="flex-1 min-w-0">
286307
<div className="text-sm font-medium">{label}</div>
287-
{!isEditing && (
308+
{!canEdit ? (
309+
<p className="text-xs text-kumo-subtle">
310+
{t`No editor is available for this block type. Its content is preserved.`}
311+
</p>
312+
) : !isEditing ? (
288313
<div className="text-xs text-kumo-subtle truncate font-mono">
289314
<bdi dir="ltr">{displayId}</bdi>
290315
</div>
291-
)}
316+
) : null}
292317
</div>
293318

294319
{/* Action buttons - visible on hover or when selected */}
@@ -298,7 +323,7 @@ function PluginBlockNodeView({
298323
selected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
299324
)}
300325
>
301-
{id && (
326+
{canEdit && id && (
302327
<>
303328
<Button
304329
type="button"
@@ -324,38 +349,40 @@ function PluginBlockNodeView({
324349
</Button>
325350
</>
326351
)}
327-
<Button
328-
type="button"
329-
variant="ghost"
330-
shape="square"
331-
className="h-8 w-8"
332-
onClick={() => {
333-
if (hasFields) {
334-
// Open Block Kit modal via editor storage callback
335-
const storage = (
336-
editor.storage as unknown as Record<string, Record<string, unknown>>
337-
).pluginBlock;
338-
const onEdit = storage?.onEditBlock as
339-
| ((attrs: {
340-
blockType: string;
341-
id: string;
342-
data: Record<string, unknown>;
343-
pos: number;
344-
}) => void)
345-
| null;
346-
if (onEdit) {
347-
const pos = (typeof getPos === "function" ? getPos() : 0) ?? 0;
348-
onEdit({ blockType, id, data, pos });
352+
{canEdit && (
353+
<Button
354+
type="button"
355+
variant="ghost"
356+
shape="square"
357+
className="h-8 w-8"
358+
onClick={() => {
359+
if (hasFields) {
360+
// Open Block Kit modal via editor storage callback
361+
const storage = (
362+
editor.storage as unknown as Record<string, Record<string, unknown>>
363+
).pluginBlock;
364+
const onEdit = storage?.onEditBlock as
365+
| ((attrs: {
366+
blockType: string;
367+
id: string;
368+
data: Record<string, unknown>;
369+
pos: number;
370+
}) => void)
371+
| null;
372+
if (onEdit) {
373+
const pos = (typeof getPos === "function" ? getPos() : 0) ?? 0;
374+
onEdit({ blockType, id, data, pos });
375+
}
376+
} else {
377+
setIsEditing(true);
349378
}
350-
} else {
351-
setIsEditing(true);
352-
}
353-
}}
354-
title={hasFields ? t`Edit` : t`Edit URL`}
355-
aria-label={hasFields ? t`Edit` : t`Edit URL`}
356-
>
357-
<Pencil className="h-4 w-4" />
358-
</Button>
379+
}}
380+
title={hasFields ? t`Edit` : t`Edit URL`}
381+
aria-label={hasFields ? t`Edit` : t`Edit URL`}
382+
>
383+
<Pencil className="h-4 w-4" />
384+
</Button>
385+
)}
359386
<Button
360387
type="button"
361388
variant="ghost"
@@ -371,7 +398,7 @@ function PluginBlockNodeView({
371398
</div>
372399

373400
{/* Inline URL editor - slides down when editing */}
374-
{isEditing && (
401+
{canEditUrl && isEditing && (
375402
<div className="px-4 pb-3 pt-0">
376403
<div className="flex gap-2">
377404
<Input
@@ -447,6 +474,7 @@ export const PluginBlockExtension = Node.create({
447474
return {
448475
/** Per-editor registry of plugin block definitions */
449476
registry: new Map<string, PluginBlockDef>(),
477+
registryListeners: new Set<() => void>(),
450478
/** Callback set by PortableTextEditor to open the Block Kit modal for editing */
451479
onEditBlock: null as
452480
| ((attrs: {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const PORTABLE_TEXT_BLOCK_ATTR = "emdashPortableTextBlock";

0 commit comments

Comments
 (0)