PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.0-beta2
Elementor Website Builder – more than just a page builder v4.3.0-beta2
4.3.0-beta2 4.3.0-beta1 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.5 4.2.0-beta2 4.2.0-dev2 4.2.0-beta1 4.1.4 4.1.3 4.1.2 4.1.1 4.1.0 4.1.0-beta3 4.1.0-dev3 4.0.9 4.1.0-beta2 4.1.0-dev2 4.0.8 4.1.0-beta1 4.1.0-dev1 4.0.7 All 451 releases
elementor / core / utils / document / document-mutator.php

document-mutator.php in Elementor Website Builder – more than just a page builder 4.3.0-beta2, at core/utils/document/document-mutator.php

507 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Elementor\Core\Utils\Document;
4
5 use Elementor\Core\Base\Document;
6 use Elementor\Plugin;
7 use Elementor\Utils;
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 class Document_Mutator {
14
15 const DOCUMENT_ROOT = 'document';
16
17 const WIDGET_EL_TYPE = 'widget';
18
19 /** @var \Elementor\Elements_Manager */
20 private $element_manager;
21
22 /** @var \Elementor\Widgets_Manager */
23 private $widgets_manager;
24
25 public function __construct( $element_manager, $widgets_manager ) {
26 $this->element_manager = $element_manager;
27 $this->widgets_manager = $widgets_manager;
28 }
29
30 public static function instance(): self {
31 return new self(
32 Plugin::$instance->elements_manager,
33 Plugin::$instance->widgets_manager
34 );
35 }
36
37 /**
38 * Locate a node by id and return a by-reference index [ $id => &$node ]
39 * so callers can mutate the tree in place.
40 *
41 * @return array<string, array&>
42 */
43 public function build_ref_index( array &$tree, string $id ): array {
44 $index = [];
45 $this->walk_for_ref_index( $tree, $id, $index );
46
47 return $index;
48 }
49
50 private function walk_for_ref_index( array &$tree, string $id, array &$index ): bool {
51 foreach ( $tree as &$node ) {
52 if ( isset( $node['id'] ) && $node['id'] === $id ) {
53 $index[ $id ] = &$node;
54 return true;
55 }
56 if ( ! empty( $node['elements'] ) && is_array( $node['elements'] ) ) {
57 if ( $this->walk_for_ref_index( $node['elements'], $id, $index ) ) {
58 return true;
59 }
60 }
61 }
62 unset( $node );
63
64 return false;
65 }
66
67 public function find_by_id( array $tree, string $id ): ?array {
68 foreach ( $tree as $node ) {
69 if ( isset( $node['id'] ) && $node['id'] === $id ) {
70 return $node;
71 }
72
73 if ( ! empty( $node['elements'] ) ) {
74 $found = $this->find_by_id( $node['elements'], $id );
75 if ( null !== $found ) {
76 return $found;
77 }
78 }
79 }
80
81 return null;
82 }
83
84 public function generate_id(): string {
85 return Utils::generate_random_string();
86 }
87
88 /**
89 * @return array|\WP_Error
90 */
91 public function insert_at( array $tree, string $parent_id, ?int $index, array $element ) {
92 if ( ! isset( $element['id'] ) ) {
93 $element['id'] = $this->generate_id();
94 }
95
96 if ( self::DOCUMENT_ROOT === $parent_id ) {
97 $root_child_error = $this->check_document_root_child_allowed( $element );
98
99 if ( is_wp_error( $root_child_error ) ) {
100 return $root_child_error;
101 }
102
103 return $this->splice_into( $tree, $index, $element );
104 }
105
106 $result = $this->insert_into_tree( $tree, $parent_id, $index, $element );
107
108 if ( is_wp_error( $result ) ) {
109 return $result;
110 }
111
112 if ( false === $result ) {
113 return new \WP_Error(
114 'elementor_not_found',
115 __( 'Parent element not found.', 'elementor' ),
116 [ 'status' => \WP_Http::NOT_FOUND ]
117 );
118 }
119
120 return $result;
121 }
122
123 /**
124 * @return array|\WP_Error
125 */
126 public function remove( array $tree, string $id ) {
127 $result = $this->remove_from_tree( $tree, $id );
128
129 if ( false === $result ) {
130 return new \WP_Error(
131 'elementor_not_found',
132 __( 'Element not found.', 'elementor' ),
133 [ 'status' => \WP_Http::NOT_FOUND ]
134 );
135 }
136
137 return $result;
138 }
139
140 /**
141 * @return array|\WP_Error
142 */
143 public function move( array $tree, string $id, string $new_parent_id, ?int $index ) {
144 $node = $this->find_by_id( $tree, $id );
145
146 if ( null === $node ) {
147 return new \WP_Error(
148 'elementor_not_found',
149 __( 'Element not found.', 'elementor' ),
150 [ 'status' => \WP_Http::NOT_FOUND ]
151 );
152 }
153
154 $tree = $this->remove( $tree, $id );
155
156 if ( is_wp_error( $tree ) ) {
157 return $tree;
158 }
159
160 return $this->insert_at( $tree, $new_parent_id, $index, $node );
161 }
162
163 /**
164 * Duplicate an element in place, inserting the clone right after the source
165 * inside the same parent. All ids in the clone are regenerated.
166 *
167 * @return array|\WP_Error
168 */
169 public function duplicate( array $tree, string $id ) {
170 $location = $this->locate_parent_and_index( $tree, $id );
171
172 if ( null === $location ) {
173 return new \WP_Error(
174 'elementor_not_found',
175 __( 'Element not found.', 'elementor' ),
176 [ 'status' => \WP_Http::NOT_FOUND ]
177 );
178 }
179
180 [ $parent_id, $index, $node ] = $location;
181
182 return $this->insert_subtree( $tree, $parent_id, $index + 1, $node );
183 }
184
185 /**
186 * Save an elements tree to a document.
187 *
188 * Default behavior (backwards compatible): downgrade a `publish` post to `draft`
189 * before saving so no live changes leak out, then save on the main document.
190 *
191 * When `$preserve_live_status` is true: the main post status is kept intact and
192 * writes on `publish`/`private` posts are redirected to an autosave revision
193 * (mirroring the editor's in-app "Save as Draft" flow) so the live page keeps
194 * serving the previous version until the user opens the editor and publishes.
195 * In this mode the return value is the Document actually written on success.
196 *
197 * @return bool|Document|\WP_Error
198 */
199 public function save_as_draft( Document $document, array $elements, bool $preserve_live_status = false ) {
200 if ( $preserve_live_status ) {
201 return $this->save_preserving_live_status( $document, $elements );
202 }
203
204 return $this->save_downgrading_publish_to_draft( $document, $elements );
205 }
206
207 /**
208 * @return bool|int|\WP_Error
209 */
210 private function save_downgrading_publish_to_draft( Document $document, array $elements ) {
211 if ( 'publish' === get_post_status( $document->get_main_id() ) ) {
212 wp_update_post( [
213 'ID' => $document->get_main_id(),
214 'post_status' => 'draft',
215 ] );
216 }
217
218 return $document->save( [ 'elements' => $elements ] );
219 }
220
221 /**
222 * @return Document|\WP_Error
223 */
224 private function save_preserving_live_status( Document $document, array $elements ) {
225 $target = $this->resolve_autosave_target( $document );
226
227 if ( is_wp_error( $target ) ) {
228 return $target;
229 }
230
231 $saved = $target->save( [ 'elements' => $elements ] );
232
233 if ( is_wp_error( $saved ) ) {
234 return $saved;
235 }
236
237 if ( ! $saved ) {
238 return new \WP_Error(
239 'elementor_save_failed',
240 __( 'Could not save document.', 'elementor' ),
241 [ 'status' => \WP_Http::INTERNAL_SERVER_ERROR ]
242 );
243 }
244
245 return $target;
246 }
247
248 /**
249 * @return Document|\WP_Error
250 */
251 private function resolve_autosave_target( Document $document ) {
252 $main_document = Plugin::$instance->documents->get( $document->get_main_id() );
253
254 if ( ! $main_document instanceof Document ) {
255 return new \WP_Error(
256 'elementor_not_found',
257 __( 'Post not found.', 'elementor' ),
258 [ 'status' => \WP_Http::NOT_FOUND ]
259 );
260 }
261
262 $main_status = get_post_status( $main_document->get_main_id() );
263
264 if ( ! in_array( $main_status, [ 'publish', 'private' ], true ) ) {
265 return $main_document;
266 }
267
268 $autosave = $main_document->get_autosave( 0, true );
269
270 if ( ! $autosave instanceof Document ) {
271 return new \WP_Error(
272 'elementor_autosave_failed',
273 __( 'Could not create autosave revision.', 'elementor' ),
274 [ 'status' => \WP_Http::INTERNAL_SERVER_ERROR ]
275 );
276 }
277
278 return $autosave;
279 }
280
281 /**
282 * @return array|\WP_Error
283 */
284 public function patch_settings( array $tree, string $id, array $partial_settings ) {
285 $result = $this->patch_settings_in_tree( $tree, $id, $partial_settings );
286
287 if ( false === $result ) {
288 return new \WP_Error(
289 'elementor_not_found',
290 __( 'Element not found.', 'elementor' ),
291 [ 'status' => \WP_Http::NOT_FOUND ]
292 );
293 }
294
295 return $result;
296 }
297
298 /**
299 * Insert a subtree into the document tree, recursively generating IDs for all elements.
300 *
301 * @param array $tree The current document tree.
302 * @param string $parent_id Parent element ID or 'document' for root.
303 * @param int|null $index Insertion index (null = append).
304 * @param array $subtree The subtree to insert (single element with nested children).
305 *
306 * @return array|\WP_Error The updated tree or error.
307 */
308 public function insert_subtree( array $tree, string $parent_id, ?int $index, array $subtree ) {
309 $subtree_with_ids = $this->generate_ids_recursive( $subtree );
310
311 return $this->insert_at( $tree, $parent_id, $index, $subtree_with_ids );
312 }
313
314 /**
315 * Recursively generate IDs for an element and all its children.
316 * Always generates new IDs to avoid duplicates when inserting subtrees.
317 *
318 * @param array $element The element to process.
319 *
320 * @return array The element with new IDs assigned.
321 */
322 private function generate_ids_recursive( array $element ): array {
323 $element['id'] = $this->generate_id();
324
325 if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
326 $element['elements'] = array_map(
327 fn( array $child ) => $this->generate_ids_recursive( $child ),
328 $element['elements']
329 );
330 }
331
332 return $element;
333 }
334
335 /**
336 * @return array{0: string, 1: int, 2: array}|null [ parent_id, index, node ] or null when not found.
337 */
338 private function locate_parent_and_index( array $tree, string $id, string $parent_id = self::DOCUMENT_ROOT ): ?array {
339 foreach ( $tree as $i => $node ) {
340 if ( isset( $node['id'] ) && $node['id'] === $id ) {
341 return [ $parent_id, $i, $node ];
342 }
343
344 if ( ! empty( $node['elements'] ) && is_array( $node['elements'] ) ) {
345 $found = $this->locate_parent_and_index( $node['elements'], $id, (string) ( $node['id'] ?? '' ) );
346 if ( null !== $found ) {
347 return $found;
348 }
349 }
350 }
351
352 return null;
353 }
354
355 private function splice_into( array $children, ?int $index, array $element ): array {
356 $count = count( $children );
357
358 if ( null === $index || $index >= $count ) {
359 $children[] = $element;
360 return $children;
361 }
362
363 array_splice( $children, $index, 0, [ $element ] );
364 return $children;
365 }
366
367 /**
368 * @return array|false|\WP_Error
369 */
370 private function insert_into_tree( array $tree, string $parent_id, ?int $index, array $element ) {
371 foreach ( $tree as $i => $node ) {
372 if ( isset( $node['id'] ) && $node['id'] === $parent_id ) {
373 if ( isset( $node['elType'] ) && self::WIDGET_EL_TYPE === $node['elType'] ) {
374 return new \WP_Error(
375 'elementor_invalid_parent',
376 __( 'Cannot insert into a widget element.', 'elementor' ),
377 [ 'status' => \WP_Http::BAD_REQUEST ]
378 );
379 }
380
381 $child_type_error = $this->check_child_type_allowed( $node, $element );
382 if ( is_wp_error( $child_type_error ) ) {
383 return $child_type_error;
384 }
385
386 $node['elements'] = $this->splice_into( $node['elements'] ?? [], $index, $element );
387 $tree[ $i ] = $node;
388 return $tree;
389 }
390
391 if ( ! empty( $node['elements'] ) ) {
392 $result = $this->insert_into_tree( $node['elements'], $parent_id, $index, $element );
393
394 if ( false !== $result ) {
395 if ( is_wp_error( $result ) ) {
396 return $result;
397 }
398 $node['elements'] = $result;
399 $tree[ $i ] = $node;
400 return $tree;
401 }
402 }
403 }
404
405 return false;
406 }
407
408 /**
409 * @return array|false
410 */
411 private function remove_from_tree( array $tree, string $id ) {
412 foreach ( $tree as $i => $node ) {
413 if ( isset( $node['id'] ) && $node['id'] === $id ) {
414 array_splice( $tree, $i, 1 );
415 return $tree;
416 }
417
418 if ( ! empty( $node['elements'] ) ) {
419 $result = $this->remove_from_tree( $node['elements'], $id );
420 if ( false !== $result ) {
421 $node['elements'] = $result;
422 $tree[ $i ] = $node;
423 return $tree;
424 }
425 }
426 }
427
428 return false;
429 }
430
431 /**
432 * Mirrors the editor's `Document.isValidChild`: only element types may sit at the document
433 * root, so widgets inserted here would produce a tree the editor itself refuses to create.
434 *
435 * @return null|\WP_Error
436 */
437 private function check_document_root_child_allowed( array $element ): ?\WP_Error {
438 if ( self::WIDGET_EL_TYPE !== ( $element['elType'] ?? '' ) ) {
439 return null;
440 }
441
442 return new \WP_Error(
443 'elementor_invalid_parent',
444 sprintf(
445 /* translators: %s: widget type */
446 __( '"%s" is a widget and cannot be a direct child of the document. Wrap it in a container element such as e-flexbox or e-div-block.', 'elementor' ),
447 $element['widgetType'] ?? self::WIDGET_EL_TYPE
448 ),
449 [ 'status' => \WP_Http::BAD_REQUEST ]
450 );
451 }
452
453 /**
454 * @return null|\WP_Error
455 */
456 private function check_child_type_allowed( array $parent_node, array $child ): ?\WP_Error {
457 $parent_instance = $this->element_manager->get_element_types( $parent_node['elType'] ?? '' );
458
459 if ( ! $parent_instance ) {
460 return null;
461 }
462
463 $config = $parent_instance->get_config();
464 $allowed = $config['allowed_child_types'] ?? [];
465
466 if ( empty( $allowed ) ) {
467 return null;
468 }
469
470 $child_type = $child['widgetType'] ?? $child['elType'] ?? '';
471
472 if ( ! in_array( $child_type, $allowed, true ) ) {
473 return new \WP_Error(
474 'elementor_invalid_parent',
475 __( 'Element type not allowed as child of this parent.', 'elementor' ),
476 [ 'status' => \WP_Http::BAD_REQUEST ]
477 );
478 }
479
480 return null;
481 }
482
483 /**
484 * @return array|false
485 */
486 private function patch_settings_in_tree( array $tree, string $id, array $partial_settings ) {
487 foreach ( $tree as $i => $node ) {
488 if ( isset( $node['id'] ) && $node['id'] === $id ) {
489 $node['settings'] = array_merge( $node['settings'] ?? [], $partial_settings );
490 $tree[ $i ] = $node;
491 return $tree;
492 }
493
494 if ( ! empty( $node['elements'] ) ) {
495 $result = $this->patch_settings_in_tree( $node['elements'], $id, $partial_settings );
496 if ( false !== $result ) {
497 $node['elements'] = $result;
498 $tree[ $i ] = $node;
499 return $tree;
500 }
501 }
502 }
503
504 return false;
505 }
506 }
507