| @@ -108,8 +108,10 @@ | ||
| 108 | 108 | $blockId, |
| 109 | 109 | $operation, |
| 110 | 110 | $trees[$owner]['wrappers'] |
| 111 | 111 | ); |
| 112 | + } elseif ($op === 'duplicate') { | |
| 113 | + $reason = self::applyDuplicate($trees[$owner]['blocks'], $blockId, $operation); | |
| 112 | 114 | } elseif (in_array($op, ['edit', 'delete', 'move'], true)) { |
| 113 | 115 | $reason = self::applyOperation($trees[$owner]['blocks'], $op, $blockId, $operation); |
| 114 | 116 | } else { |
| 115 | 117 | $reason = 'unknown op'; |
| @@ -296,10 +298,10 @@ | ||
| 296 | 298 | // Returns null when the block spliced in next to its anchor, or the refusal reason. |
| 297 | 299 | private static function applyAdd(array &$blocks, int $anchorId, array $operation, array &$sharedWrappers) |
| 298 | 300 | { |
| 299 | 301 | $position = (string) ($operation['position'] ?? ''); |
| 300 | - if (!in_array($position, ['before', 'after'], true)) { | |
| 301 | - return "position must be 'before' or 'after'"; | |
| 302 | + if (!in_array($position, ['before', 'after', 'inside'], true)) { | |
| 303 | + return "position must be 'before', 'after' or 'inside'"; | |
| 302 | 304 | } |
| 303 | 305 | $newBlock = self::parseSingleBlock((string) ($operation['block'] ?? '')); |
| 304 | 306 | if (!$newBlock) { |
| 305 | 307 | return 'block must parse to exactly one block'; |
| @@ -307,8 +309,11 @@ | ||
| 307 | 309 | $anchorPath = PostBlockFinder::pathByRef($blocks, $anchorId); |
| 308 | 310 | if ($anchorPath === null) { |
| 309 | 311 | return 'anchor block not found in this post'; |
| 310 | 312 | } |
| 313 | + if ($position === 'inside') { | |
| 314 | + return self::nestInMenuItem($blocks, $anchorPath, $newBlock); | |
| 315 | + } | |
| 311 | 316 | // A bare column is only valid as a core/columns child, so its anchor |
| 312 | 317 | // decides the splice here — the code owns the wrapper, never the model. |
| 313 | 318 | if (($newBlock['blockName'] ?? '') === 'core/column') { |
| 314 | 319 | return self::spliceColumn($blocks, $anchorPath, $newBlock, $position, $anchorId, $sharedWrappers); |
| @@ -316,8 +321,35 @@ | ||
| 316 | 321 | $blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position); |
| 317 | 322 | return null; |
| 318 | 323 | } |
| 319 | 324 | |
| 325 | + // The editor's "Add submenu": the link becomes a submenu keeping its label and URL. | |
| 326 | + private static function nestInMenuItem(array &$blocks, array $anchorPath, array $newBlock) | |
| 327 | + { | |
| 328 | + $anchor = self::blockAtPath($blocks, $anchorPath); | |
| 329 | + $anchorName = $anchor['blockName'] ?? ''; | |
| 330 | + if (!in_array($anchorName, ['core/navigation-link', 'core/navigation-submenu'], true)) { | |
| 331 | + return "position 'inside' needs a menu item as the anchor"; | |
| 332 | + } | |
| 333 | + if (($newBlock['blockName'] ?? '') !== 'core/navigation-link') { | |
| 334 | + return 'only a menu link can go inside a menu item'; | |
| 335 | + } | |
| 336 | + // A child claiming top level renders as if it sat beside its parent. | |
| 337 | + unset($newBlock['attrs']['isTopLevelLink']); | |
| 338 | + if ($anchorName === 'core/navigation-link') { | |
| 339 | + $anchor['blockName'] = 'core/navigation-submenu'; | |
| 340 | + $attrs = []; | |
| 341 | + foreach (($anchor['attrs'] ?? []) as $key => $value) { | |
| 342 | + $attrs[$key === 'isTopLevelLink' ? 'isTopLevelItem' : $key] = $value; | |
| 343 | + } | |
| 344 | + $anchor['attrs'] = $attrs; | |
| 345 | + } | |
| 346 | + $anchor['innerBlocks'][] = $newBlock; | |
| 347 | + $anchor['innerContent'][] = null; | |
| 348 | + $blocks = self::spliceAtPath($blocks, $anchorPath, $anchor); | |
| 349 | + return null; | |
| 350 | + } | |
| 351 | + | |
| 320 | 352 | // A pre-existing section never absorbs an add — the only wrapper an add |
| 321 | 353 | // joins is one this same batch created. |
| 322 | 354 | private static function spliceColumn( |
| 323 | 355 | array &$blocks, |
| @@ -422,8 +454,53 @@ | ||
| 422 | 454 | $blocks = self::insertAtPath($blocks, $lastChild, $column, 'after'); |
| 423 | 455 | $sharedWrappers[$blockId . ':after'] = $shellRef; |
| 424 | 456 | $sharedWrappers[$blockId . ':before'] = $shellRef; |
| 425 | 457 | return true; |
| 458 | + } | |
| 459 | + | |
| 460 | + // Returns null when the copy spliced in, or the refusal reason. | |
| 461 | + private static function applyDuplicate(array &$blocks, int $blockId, array $operation) | |
| 462 | + { | |
| 463 | + $path = PostBlockFinder::pathByRef($blocks, $blockId); | |
| 464 | + if ($path === null) { | |
| 465 | + return 'block id not found in this post'; | |
| 466 | + } | |
| 467 | + $original = self::blockAtPath($blocks, $path); | |
| 468 | + $copy = self::withoutStamps($original); | |
| 469 | + if (isset($operation['block'])) { | |
| 470 | + $copy = self::parseSingleBlock((string) $operation['block']); | |
| 471 | + if (!$copy) { | |
| 472 | + return 'block must parse to exactly one block'; | |
| 473 | + } | |
| 474 | + if (($copy['blockName'] ?? '') !== ($original['blockName'] ?? '')) { | |
| 475 | + return 'a copy must keep the original block type'; | |
| 476 | + } | |
| 477 | + } | |
| 478 | + $anchorPath = $path; | |
| 479 | + $position = 'after'; | |
| 480 | + if (isset($operation['targetId'])) { | |
| 481 | + $anchorPath = PostBlockFinder::pathByRef($blocks, (int) $operation['targetId']); | |
| 482 | + if ($anchorPath === null) { | |
| 483 | + return 'target block not found in this post'; | |
| 484 | + } | |
| 485 | + if (array_slice($anchorPath, 0, count($path)) === $path && $anchorPath !== $path) { | |
| 486 | + return 'target is inside the copied block'; | |
| 487 | + } | |
| 488 | + $position = (string) ($operation['position'] ?? 'after'); | |
| 489 | + if (!in_array($position, ['before', 'after'], true)) { | |
| 490 | + return "position must be 'before' or 'after'"; | |
| 491 | + } | |
| 492 | + } | |
| 493 | + $blocks = self::insertAtPath($blocks, $anchorPath, $copy, $position); | |
| 494 | + return null; | |
| 495 | + } | |
| 496 | + | |
| 497 | + // A stamped copy would catch later ops aimed at the original's ids. | |
| 498 | + private static function withoutStamps(array $block): array | |
| 499 | + { | |
| 500 | + unset($block[PostBlockFinder::REF_KEY]); | |
| 501 | + $block['innerBlocks'] = array_map([self::class, 'withoutStamps'], $block['innerBlocks'] ?? []); | |
| 502 | + return $block; | |
| 426 | 503 | } |
| 427 | 504 | |
| 428 | 505 | private static function blockAtPath(array $blocks, array $path) |
| 429 | 506 | { |