@elementor/editor-canvas 4.1.0-737 → 4.1.0-739

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1187,6 +1187,9 @@ function getArgsElementType(args) {
1187
1187
  function getElementType(element) {
1188
1188
  return element?.model.get("widgetType") || element?.model.get("elType");
1189
1189
  }
1190
+ function getClipboardElementType(element) {
1191
+ return element?.widgetType || element?.elType;
1192
+ }
1190
1193
  function isElementWithinFormSelector(element) {
1191
1194
  return !!element?.view?.el?.closest('form,[data-element_type="e-form"]');
1192
1195
  }
@@ -1204,7 +1207,7 @@ function hasElementTypes(element, types) {
1204
1207
  }
1205
1208
  function hasClipboardElementType(elements, type) {
1206
1209
  return elements.some((element) => {
1207
- const elementType = element.widgetType || element.elType;
1210
+ const elementType = getClipboardElementType(element);
1208
1211
  if (elementType === type) {
1209
1212
  return true;
1210
1213
  }
@@ -1213,13 +1216,22 @@ function hasClipboardElementType(elements, type) {
1213
1216
  }
1214
1217
  function hasClipboardElementTypes(elements, types) {
1215
1218
  return elements.some((element) => {
1216
- const elementType = element.widgetType || element.elType;
1219
+ const elementType = getClipboardElementType(element);
1217
1220
  if (elementType && types.has(elementType)) {
1218
1221
  return true;
1219
1222
  }
1220
1223
  return element.elements ? hasClipboardElementTypes(element.elements, types) : false;
1221
1224
  });
1222
1225
  }
1226
+ function movedContainersIncludeAtomicFormRoot(containers) {
1227
+ return containers.some((container) => getElementType(container) === FORM_ELEMENT_TYPE);
1228
+ }
1229
+ function clipboardRootsAreAtomicForms(elements) {
1230
+ if (!elements.length) {
1231
+ return false;
1232
+ }
1233
+ return elements.every((el) => getClipboardElementType(el) === FORM_ELEMENT_TYPE);
1234
+ }
1223
1235
 
1224
1236
  // src/form-structure/enforce-form-ancestor-commands.ts
1225
1237
  var FORM_FIELDS_OUTSIDE_ALERT = {
@@ -1257,7 +1269,7 @@ function blockFormFieldMove(args) {
1257
1269
  const hasFormFieldElement = containers.some(
1258
1270
  (container) => container ? hasElementTypes(container, FORM_FIELD_ELEMENT_TYPES) : false
1259
1271
  );
1260
- if (hasFormFieldElement && !isWithinForm(target)) {
1272
+ if (hasFormFieldElement && !isWithinForm(target) && !movedContainersIncludeAtomicFormRoot(containers)) {
1261
1273
  handleBlockedFormField();
1262
1274
  return true;
1263
1275
  }
@@ -1273,7 +1285,7 @@ function blockFormFieldPaste(args) {
1273
1285
  return false;
1274
1286
  }
1275
1287
  const hasFormFieldElement = hasClipboardElementTypes(data.clipboard.elements, FORM_FIELD_ELEMENT_TYPES);
1276
- if (hasFormFieldElement && !isWithinForm(args.container)) {
1288
+ if (hasFormFieldElement && !isWithinForm(args.container) && !clipboardRootsAreAtomicForms(data.clipboard.elements)) {
1277
1289
  handleBlockedFormField();
1278
1290
  return true;
1279
1291
  }
@@ -1453,14 +1465,74 @@ var plainTransformer = createTransformer((value) => {
1453
1465
  return value;
1454
1466
  });
1455
1467
 
1456
- // src/transformers/shared/video-src-transformer.ts
1468
+ // src/transformers/shared/svg-src-transformer.ts
1469
+ var import_dompurify = __toESM(require("dompurify"));
1457
1470
  var import_wp_media2 = require("@elementor/wp-media");
1471
+ var SVG_INLINE_STYLES = "width: 100%; height: 100%; overflow: unset;";
1472
+ function processSvgContent(svgText) {
1473
+ const sanitized = import_dompurify.default.sanitize(svgText, {
1474
+ USE_PROFILES: { svg: true, svgFilters: true }
1475
+ });
1476
+ const parser = new DOMParser();
1477
+ const doc = parser.parseFromString(sanitized, "image/svg+xml");
1478
+ const svgElement = doc.querySelector("svg");
1479
+ if (!svgElement) {
1480
+ return null;
1481
+ }
1482
+ svgElement.setAttribute("fill", "currentColor");
1483
+ const existingStyle = svgElement.getAttribute("style") ?? "";
1484
+ const trimmed = existingStyle.trim();
1485
+ const merged = trimmed ? `${trimmed.replace(/;$/, "")}; ${SVG_INLINE_STYLES}` : SVG_INLINE_STYLES;
1486
+ svgElement.setAttribute("style", merged);
1487
+ return svgElement.outerHTML;
1488
+ }
1489
+ async function fetchSvgContent(url, signal) {
1490
+ try {
1491
+ const response = await fetch(url, { signal });
1492
+ if (!response.ok) {
1493
+ return null;
1494
+ }
1495
+ const contentType = response.headers.get("content-type") ?? "";
1496
+ const isSvg = contentType.includes("svg") || contentType.includes("xml") || url.endsWith(".svg");
1497
+ if (!isSvg) {
1498
+ return null;
1499
+ }
1500
+ return await response.text();
1501
+ } catch {
1502
+ return null;
1503
+ }
1504
+ }
1505
+ function resolveSvgSrcId(id) {
1506
+ if (typeof id !== "number" || id <= 0) {
1507
+ return null;
1508
+ }
1509
+ return id;
1510
+ }
1511
+ var svgSrcTransformer = createTransformer(async (value, { signal }) => {
1512
+ const id = resolveSvgSrcId(value.id);
1513
+ const urlFromValue = typeof value.url === "string" ? value.url : null;
1514
+ let url = urlFromValue;
1515
+ if (id && !urlFromValue) {
1516
+ const attachment = await (0, import_wp_media2.getMediaAttachment)({ id });
1517
+ url = attachment?.url ?? null;
1518
+ }
1519
+ const resolvedUrl = typeof url === "string" ? url : null;
1520
+ if (!resolvedUrl) {
1521
+ return { html: null, url: null };
1522
+ }
1523
+ const svgText = await fetchSvgContent(resolvedUrl, signal);
1524
+ const html = svgText ? processSvgContent(svgText) : null;
1525
+ return { html, url: resolvedUrl };
1526
+ });
1527
+
1528
+ // src/transformers/shared/video-src-transformer.ts
1529
+ var import_wp_media3 = require("@elementor/wp-media");
1458
1530
  var videoSrcTransformer = createTransformer(async (value) => {
1459
1531
  const { id, url } = value;
1460
1532
  if (!id) {
1461
1533
  return { id: null, url };
1462
1534
  }
1463
- const attachment = await (0, import_wp_media2.getMediaAttachment)({ id });
1535
+ const attachment = await (0, import_wp_media3.getMediaAttachment)({ id });
1464
1536
  return {
1465
1537
  id,
1466
1538
  url: attachment?.url ?? url
@@ -1469,7 +1541,7 @@ var videoSrcTransformer = createTransformer(async (value) => {
1469
1541
 
1470
1542
  // src/init-settings-transformers.ts
1471
1543
  function initSettingsTransformers() {
1472
- settingsTransformersRegistry.register("classes", createClassesTransformer()).register("link", linkTransformer).register("query", queryTransformer).register("image", imageTransformer).register("image-src", imageSrcTransformer).register("video-src", videoSrcTransformer).register("attributes", attributesTransformer).register("date-time", dateTimeTransformer).register("html-v2", htmlV2Transformer).register("html-v3", htmlV3Transformer).registerFallback(plainTransformer);
1544
+ settingsTransformersRegistry.register("classes", createClassesTransformer()).register("link", linkTransformer).register("query", queryTransformer).register("image", imageTransformer).register("image-src", imageSrcTransformer).register("svg-src", svgSrcTransformer).register("video-src", videoSrcTransformer).register("attributes", attributesTransformer).register("date-time", dateTimeTransformer).register("html-v2", htmlV2Transformer).register("html-v3", htmlV3Transformer).registerFallback(plainTransformer);
1473
1545
  }
1474
1546
 
1475
1547
  // src/transformers/styles/background-color-overlay-transformer.ts
package/dist/index.mjs CHANGED
@@ -1153,6 +1153,9 @@ function getArgsElementType(args) {
1153
1153
  function getElementType(element) {
1154
1154
  return element?.model.get("widgetType") || element?.model.get("elType");
1155
1155
  }
1156
+ function getClipboardElementType(element) {
1157
+ return element?.widgetType || element?.elType;
1158
+ }
1156
1159
  function isElementWithinFormSelector(element) {
1157
1160
  return !!element?.view?.el?.closest('form,[data-element_type="e-form"]');
1158
1161
  }
@@ -1170,7 +1173,7 @@ function hasElementTypes(element, types) {
1170
1173
  }
1171
1174
  function hasClipboardElementType(elements, type) {
1172
1175
  return elements.some((element) => {
1173
- const elementType = element.widgetType || element.elType;
1176
+ const elementType = getClipboardElementType(element);
1174
1177
  if (elementType === type) {
1175
1178
  return true;
1176
1179
  }
@@ -1179,13 +1182,22 @@ function hasClipboardElementType(elements, type) {
1179
1182
  }
1180
1183
  function hasClipboardElementTypes(elements, types) {
1181
1184
  return elements.some((element) => {
1182
- const elementType = element.widgetType || element.elType;
1185
+ const elementType = getClipboardElementType(element);
1183
1186
  if (elementType && types.has(elementType)) {
1184
1187
  return true;
1185
1188
  }
1186
1189
  return element.elements ? hasClipboardElementTypes(element.elements, types) : false;
1187
1190
  });
1188
1191
  }
1192
+ function movedContainersIncludeAtomicFormRoot(containers) {
1193
+ return containers.some((container) => getElementType(container) === FORM_ELEMENT_TYPE);
1194
+ }
1195
+ function clipboardRootsAreAtomicForms(elements) {
1196
+ if (!elements.length) {
1197
+ return false;
1198
+ }
1199
+ return elements.every((el) => getClipboardElementType(el) === FORM_ELEMENT_TYPE);
1200
+ }
1189
1201
 
1190
1202
  // src/form-structure/enforce-form-ancestor-commands.ts
1191
1203
  var FORM_FIELDS_OUTSIDE_ALERT = {
@@ -1223,7 +1235,7 @@ function blockFormFieldMove(args) {
1223
1235
  const hasFormFieldElement = containers.some(
1224
1236
  (container) => container ? hasElementTypes(container, FORM_FIELD_ELEMENT_TYPES) : false
1225
1237
  );
1226
- if (hasFormFieldElement && !isWithinForm(target)) {
1238
+ if (hasFormFieldElement && !isWithinForm(target) && !movedContainersIncludeAtomicFormRoot(containers)) {
1227
1239
  handleBlockedFormField();
1228
1240
  return true;
1229
1241
  }
@@ -1239,7 +1251,7 @@ function blockFormFieldPaste(args) {
1239
1251
  return false;
1240
1252
  }
1241
1253
  const hasFormFieldElement = hasClipboardElementTypes(data.clipboard.elements, FORM_FIELD_ELEMENT_TYPES);
1242
- if (hasFormFieldElement && !isWithinForm(args.container)) {
1254
+ if (hasFormFieldElement && !isWithinForm(args.container) && !clipboardRootsAreAtomicForms(data.clipboard.elements)) {
1243
1255
  handleBlockedFormField();
1244
1256
  return true;
1245
1257
  }
@@ -1419,14 +1431,74 @@ var plainTransformer = createTransformer((value) => {
1419
1431
  return value;
1420
1432
  });
1421
1433
 
1422
- // src/transformers/shared/video-src-transformer.ts
1434
+ // src/transformers/shared/svg-src-transformer.ts
1435
+ import DOMPurify from "dompurify";
1423
1436
  import { getMediaAttachment as getMediaAttachment2 } from "@elementor/wp-media";
1437
+ var SVG_INLINE_STYLES = "width: 100%; height: 100%; overflow: unset;";
1438
+ function processSvgContent(svgText) {
1439
+ const sanitized = DOMPurify.sanitize(svgText, {
1440
+ USE_PROFILES: { svg: true, svgFilters: true }
1441
+ });
1442
+ const parser = new DOMParser();
1443
+ const doc = parser.parseFromString(sanitized, "image/svg+xml");
1444
+ const svgElement = doc.querySelector("svg");
1445
+ if (!svgElement) {
1446
+ return null;
1447
+ }
1448
+ svgElement.setAttribute("fill", "currentColor");
1449
+ const existingStyle = svgElement.getAttribute("style") ?? "";
1450
+ const trimmed = existingStyle.trim();
1451
+ const merged = trimmed ? `${trimmed.replace(/;$/, "")}; ${SVG_INLINE_STYLES}` : SVG_INLINE_STYLES;
1452
+ svgElement.setAttribute("style", merged);
1453
+ return svgElement.outerHTML;
1454
+ }
1455
+ async function fetchSvgContent(url, signal) {
1456
+ try {
1457
+ const response = await fetch(url, { signal });
1458
+ if (!response.ok) {
1459
+ return null;
1460
+ }
1461
+ const contentType = response.headers.get("content-type") ?? "";
1462
+ const isSvg = contentType.includes("svg") || contentType.includes("xml") || url.endsWith(".svg");
1463
+ if (!isSvg) {
1464
+ return null;
1465
+ }
1466
+ return await response.text();
1467
+ } catch {
1468
+ return null;
1469
+ }
1470
+ }
1471
+ function resolveSvgSrcId(id) {
1472
+ if (typeof id !== "number" || id <= 0) {
1473
+ return null;
1474
+ }
1475
+ return id;
1476
+ }
1477
+ var svgSrcTransformer = createTransformer(async (value, { signal }) => {
1478
+ const id = resolveSvgSrcId(value.id);
1479
+ const urlFromValue = typeof value.url === "string" ? value.url : null;
1480
+ let url = urlFromValue;
1481
+ if (id && !urlFromValue) {
1482
+ const attachment = await getMediaAttachment2({ id });
1483
+ url = attachment?.url ?? null;
1484
+ }
1485
+ const resolvedUrl = typeof url === "string" ? url : null;
1486
+ if (!resolvedUrl) {
1487
+ return { html: null, url: null };
1488
+ }
1489
+ const svgText = await fetchSvgContent(resolvedUrl, signal);
1490
+ const html = svgText ? processSvgContent(svgText) : null;
1491
+ return { html, url: resolvedUrl };
1492
+ });
1493
+
1494
+ // src/transformers/shared/video-src-transformer.ts
1495
+ import { getMediaAttachment as getMediaAttachment3 } from "@elementor/wp-media";
1424
1496
  var videoSrcTransformer = createTransformer(async (value) => {
1425
1497
  const { id, url } = value;
1426
1498
  if (!id) {
1427
1499
  return { id: null, url };
1428
1500
  }
1429
- const attachment = await getMediaAttachment2({ id });
1501
+ const attachment = await getMediaAttachment3({ id });
1430
1502
  return {
1431
1503
  id,
1432
1504
  url: attachment?.url ?? url
@@ -1435,7 +1507,7 @@ var videoSrcTransformer = createTransformer(async (value) => {
1435
1507
 
1436
1508
  // src/init-settings-transformers.ts
1437
1509
  function initSettingsTransformers() {
1438
- settingsTransformersRegistry.register("classes", createClassesTransformer()).register("link", linkTransformer).register("query", queryTransformer).register("image", imageTransformer).register("image-src", imageSrcTransformer).register("video-src", videoSrcTransformer).register("attributes", attributesTransformer).register("date-time", dateTimeTransformer).register("html-v2", htmlV2Transformer).register("html-v3", htmlV3Transformer).registerFallback(plainTransformer);
1510
+ settingsTransformersRegistry.register("classes", createClassesTransformer()).register("link", linkTransformer).register("query", queryTransformer).register("image", imageTransformer).register("image-src", imageSrcTransformer).register("svg-src", svgSrcTransformer).register("video-src", videoSrcTransformer).register("attributes", attributesTransformer).register("date-time", dateTimeTransformer).register("html-v2", htmlV2Transformer).register("html-v3", htmlV3Transformer).registerFallback(plainTransformer);
1439
1511
  }
1440
1512
 
1441
1513
  // src/transformers/styles/background-color-overlay-transformer.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elementor/editor-canvas",
3
3
  "description": "Elementor Editor Canvas",
4
- "version": "4.1.0-737",
4
+ "version": "4.1.0-739",
5
5
  "private": false,
6
6
  "author": "Elementor Team",
7
7
  "homepage": "https://elementor.com/",
@@ -37,24 +37,25 @@
37
37
  "react-dom": "^18.3.1"
38
38
  },
39
39
  "dependencies": {
40
- "@elementor/editor": "4.1.0-737",
41
- "@elementor/editor-controls": "4.1.0-737",
42
- "@elementor/editor-documents": "4.1.0-737",
43
- "@elementor/editor-elements": "4.1.0-737",
44
- "@elementor/editor-interactions": "4.1.0-737",
45
- "@elementor/editor-mcp": "4.1.0-737",
46
- "@elementor/editor-notifications": "4.1.0-737",
47
- "@elementor/editor-props": "4.1.0-737",
48
- "@elementor/editor-responsive": "4.1.0-737",
49
- "@elementor/editor-styles": "4.1.0-737",
50
- "@elementor/editor-styles-repository": "4.1.0-737",
51
- "@elementor/editor-ui": "4.1.0-737",
52
- "@elementor/editor-v1-adapters": "4.1.0-737",
53
- "@elementor/schema": "4.1.0-737",
54
- "@elementor/twing": "4.1.0-737",
40
+ "@elementor/editor": "4.1.0-739",
41
+ "dompurify": "^3.2.6",
42
+ "@elementor/editor-controls": "4.1.0-739",
43
+ "@elementor/editor-documents": "4.1.0-739",
44
+ "@elementor/editor-elements": "4.1.0-739",
45
+ "@elementor/editor-interactions": "4.1.0-739",
46
+ "@elementor/editor-mcp": "4.1.0-739",
47
+ "@elementor/editor-notifications": "4.1.0-739",
48
+ "@elementor/editor-props": "4.1.0-739",
49
+ "@elementor/editor-responsive": "4.1.0-739",
50
+ "@elementor/editor-styles": "4.1.0-739",
51
+ "@elementor/editor-styles-repository": "4.1.0-739",
52
+ "@elementor/editor-ui": "4.1.0-739",
53
+ "@elementor/editor-v1-adapters": "4.1.0-739",
54
+ "@elementor/schema": "4.1.0-739",
55
+ "@elementor/twing": "4.1.0-739",
55
56
  "@elementor/ui": "1.36.17",
56
- "@elementor/utils": "4.1.0-737",
57
- "@elementor/wp-media": "4.1.0-737",
57
+ "@elementor/utils": "4.1.0-739",
58
+ "@elementor/wp-media": "4.1.0-739",
58
59
  "@floating-ui/react": "^0.27.5",
59
60
  "@wordpress/i18n": "^5.13.0"
60
61
  },
@@ -0,0 +1,70 @@
1
+ import type { V1Element } from '@elementor/editor-elements';
2
+
3
+ import {
4
+ clipboardRootsAreAtomicForms,
5
+ FORM_ELEMENT_TYPE,
6
+ getClipboardElementType,
7
+ movedContainersIncludeAtomicFormRoot,
8
+ } from '../utils';
9
+
10
+ function mockElement( widgetType?: string, elType?: string ): V1Element {
11
+ return {
12
+ model: {
13
+ get: ( key: string ) => {
14
+ if ( key === 'widgetType' ) {
15
+ return widgetType;
16
+ }
17
+ if ( key === 'elType' ) {
18
+ return elType;
19
+ }
20
+ return undefined;
21
+ },
22
+ },
23
+ } as V1Element;
24
+ }
25
+
26
+ describe( 'form-structure utils', () => {
27
+ describe( 'getClipboardElementType', () => {
28
+ it( 'prefers widgetType over elType', () => {
29
+ expect( getClipboardElementType( { widgetType: 'e-form-input', elType: 'widget' } ) ).toBe(
30
+ 'e-form-input'
31
+ );
32
+ } );
33
+
34
+ it( 'falls back to elType', () => {
35
+ expect( getClipboardElementType( { elType: FORM_ELEMENT_TYPE } ) ).toBe( FORM_ELEMENT_TYPE );
36
+ } );
37
+
38
+ it( 'returns undefined when absent', () => {
39
+ expect( getClipboardElementType( {} ) ).toBeUndefined();
40
+ } );
41
+ } );
42
+
43
+ describe( 'movedContainersIncludeAtomicFormRoot', () => {
44
+ it( 'returns true when a top-level moved container is e-form', () => {
45
+ const form = mockElement( undefined, FORM_ELEMENT_TYPE );
46
+
47
+ expect( movedContainersIncludeAtomicFormRoot( [ form ] ) ).toBe( true );
48
+ } );
49
+
50
+ it( 'returns false when moved containers are only form fields', () => {
51
+ const input = mockElement( 'e-form-input', 'widget' );
52
+
53
+ expect( movedContainersIncludeAtomicFormRoot( [ input ] ) ).toBe( false );
54
+ } );
55
+ } );
56
+
57
+ describe( 'clipboardRootsAreAtomicForms', () => {
58
+ it( 'returns true when every root is e-form', () => {
59
+ expect( clipboardRootsAreAtomicForms( [ { elType: FORM_ELEMENT_TYPE, elements: [] } ] ) ).toBe( true );
60
+ } );
61
+
62
+ it( 'returns false when a root is a bare form field', () => {
63
+ expect( clipboardRootsAreAtomicForms( [ { widgetType: 'e-form-input', elements: [] } ] ) ).toBe( false );
64
+ } );
65
+
66
+ it( 'returns false for empty roots', () => {
67
+ expect( clipboardRootsAreAtomicForms( [] ) ).toBe( false );
68
+ } );
69
+ } );
70
+ } );
@@ -3,6 +3,7 @@ import { blockCommand } from '@elementor/editor-v1-adapters';
3
3
  import { __ } from '@wordpress/i18n';
4
4
 
5
5
  import {
6
+ clipboardRootsAreAtomicForms,
6
7
  type CreateArgs,
7
8
  FORM_FIELD_ELEMENT_TYPES,
8
9
  getArgsElementType,
@@ -10,6 +11,7 @@ import {
10
11
  hasElementTypes,
11
12
  isWithinForm,
12
13
  type MoveArgs,
14
+ movedContainersIncludeAtomicFormRoot,
13
15
  type PasteArgs,
14
16
  type StorageContent,
15
17
  } from './utils';
@@ -60,7 +62,7 @@ function blockFormFieldMove( args: MoveArgs ): boolean {
60
62
  container ? hasElementTypes( container, FORM_FIELD_ELEMENT_TYPES ) : false
61
63
  );
62
64
 
63
- if ( hasFormFieldElement && ! isWithinForm( target ) ) {
65
+ if ( hasFormFieldElement && ! isWithinForm( target ) && ! movedContainersIncludeAtomicFormRoot( containers ) ) {
64
66
  handleBlockedFormField();
65
67
 
66
68
  return true;
@@ -86,7 +88,11 @@ function blockFormFieldPaste( args: PasteArgs ): boolean {
86
88
 
87
89
  const hasFormFieldElement = hasClipboardElementTypes( data.clipboard.elements, FORM_FIELD_ELEMENT_TYPES );
88
90
 
89
- if ( hasFormFieldElement && ! isWithinForm( args.container ) ) {
91
+ if (
92
+ hasFormFieldElement &&
93
+ ! isWithinForm( args.container ) &&
94
+ ! clipboardRootsAreAtomicForms( data.clipboard.elements )
95
+ ) {
90
96
  handleBlockedFormField();
91
97
 
92
98
  return true;
@@ -50,6 +50,10 @@ export function getElementType( element?: V1Element ): string | undefined {
50
50
  return element?.model.get( 'widgetType' ) || element?.model.get( 'elType' );
51
51
  }
52
52
 
53
+ export function getClipboardElementType( element?: ClipboardElement ): string | undefined {
54
+ return element?.widgetType || element?.elType;
55
+ }
56
+
53
57
  export function isElementWithinFormSelector( element?: V1Element ): boolean {
54
58
  return !! element?.view?.el?.closest( 'form,[data-element_type="e-form"]' );
55
59
  }
@@ -72,7 +76,7 @@ export function hasElementTypes( element: V1Element, types: Set< string > ): boo
72
76
 
73
77
  export function hasClipboardElementType( elements: ClipboardElement[], type: string ): boolean {
74
78
  return elements.some( ( element ) => {
75
- const elementType = element.widgetType || element.elType;
79
+ const elementType = getClipboardElementType( element );
76
80
 
77
81
  if ( elementType === type ) {
78
82
  return true;
@@ -84,7 +88,7 @@ export function hasClipboardElementType( elements: ClipboardElement[], type: str
84
88
 
85
89
  export function hasClipboardElementTypes( elements: ClipboardElement[], types: Set< string > ): boolean {
86
90
  return elements.some( ( element ) => {
87
- const elementType = element.widgetType || element.elType;
91
+ const elementType = getClipboardElementType( element );
88
92
 
89
93
  if ( elementType && types.has( elementType ) ) {
90
94
  return true;
@@ -93,3 +97,15 @@ export function hasClipboardElementTypes( elements: ClipboardElement[], types: S
93
97
  return element.elements ? hasClipboardElementTypes( element.elements, types ) : false;
94
98
  } );
95
99
  }
100
+
101
+ export function movedContainersIncludeAtomicFormRoot( containers: ( V1Element | undefined )[] ): boolean {
102
+ return containers.some( ( container ) => getElementType( container ) === FORM_ELEMENT_TYPE );
103
+ }
104
+
105
+ export function clipboardRootsAreAtomicForms( elements: ClipboardElement[] ): boolean {
106
+ if ( ! elements.length ) {
107
+ return false;
108
+ }
109
+
110
+ return elements.every( ( el ) => getClipboardElementType( el ) === FORM_ELEMENT_TYPE );
111
+ }
@@ -9,6 +9,7 @@ import { queryTransformer } from './transformers/settings/query-transformer';
9
9
  import { imageSrcTransformer } from './transformers/shared/image-src-transformer';
10
10
  import { imageTransformer } from './transformers/shared/image-transformer';
11
11
  import { plainTransformer } from './transformers/shared/plain-transformer';
12
+ import { svgSrcTransformer } from './transformers/shared/svg-src-transformer';
12
13
  import { videoSrcTransformer } from './transformers/shared/video-src-transformer';
13
14
 
14
15
  export function initSettingsTransformers() {
@@ -18,6 +19,7 @@ export function initSettingsTransformers() {
18
19
  .register( 'query', queryTransformer )
19
20
  .register( 'image', imageTransformer )
20
21
  .register( 'image-src', imageSrcTransformer )
22
+ .register( 'svg-src', svgSrcTransformer )
21
23
  .register( 'video-src', videoSrcTransformer )
22
24
  .register( 'attributes', attributesTransformer )
23
25
  .register( 'date-time', dateTimeTransformer )
@@ -0,0 +1,184 @@
1
+ import { getMediaAttachment } from '@elementor/wp-media';
2
+
3
+ import { svgSrcTransformer } from '../svg-src-transformer';
4
+
5
+ jest.mock( '@elementor/wp-media' );
6
+
7
+ const mockedGetMediaAttachment = jest.mocked( getMediaAttachment );
8
+
9
+ const SAMPLE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><path d="M0 0h100v100H0z"/></svg>';
10
+
11
+ const mockFetch = ( body: string, contentType = 'image/svg+xml', ok = true ) => {
12
+ global.fetch = jest.fn().mockResolvedValue( {
13
+ ok,
14
+ headers: new Headers( { 'content-type': contentType } ),
15
+ text: () => Promise.resolve( body ),
16
+ } );
17
+ };
18
+
19
+ describe( 'svgSrcTransformer', () => {
20
+ beforeEach( () => {
21
+ jest.clearAllMocks();
22
+ } );
23
+
24
+ afterEach( () => {
25
+ jest.restoreAllMocks();
26
+ } );
27
+
28
+ it( 'fetches SVG and returns processed html with currentColor fill', async () => {
29
+ // Arrange.
30
+ mockFetch( SAMPLE_SVG );
31
+
32
+ // Act.
33
+ const result = await svgSrcTransformer( { id: null, url: 'https://example.com/icon.svg' }, { key: 'svg' } );
34
+
35
+ // Assert.
36
+ expect( result ).toEqual( {
37
+ html: expect.stringContaining( 'fill="currentColor"' ),
38
+ url: 'https://example.com/icon.svg',
39
+ } );
40
+ } );
41
+
42
+ it( 'adds inline styles to the svg element', async () => {
43
+ // Arrange.
44
+ mockFetch( SAMPLE_SVG );
45
+
46
+ // Act.
47
+ const result = ( await svgSrcTransformer(
48
+ { id: null, url: 'https://example.com/icon.svg' },
49
+ { key: 'svg' }
50
+ ) ) as { html: string; url: string };
51
+
52
+ // Assert.
53
+ expect( result.html ).toContain( 'width: 100%' );
54
+ expect( result.html ).toContain( 'height: 100%' );
55
+ expect( result.html ).toContain( 'overflow: unset' );
56
+ } );
57
+
58
+ it( 'returns null html when url is null', async () => {
59
+ // Arrange & Act.
60
+ const result = await svgSrcTransformer( { id: null, url: null }, { key: 'svg' } );
61
+
62
+ // Assert.
63
+ expect( result ).toEqual( { html: null, url: null } );
64
+ expect( mockedGetMediaAttachment ).not.toHaveBeenCalled();
65
+ } );
66
+
67
+ it( 'resolves url from attachment id then fetches SVG', async () => {
68
+ // Arrange.
69
+ mockedGetMediaAttachment.mockResolvedValue( {
70
+ url: 'https://example.com/resolved.svg',
71
+ } as never );
72
+ mockFetch( SAMPLE_SVG );
73
+
74
+ // Act.
75
+ const result = await svgSrcTransformer( { id: 42, url: null }, { key: 'svg' } );
76
+
77
+ // Assert.
78
+ expect( mockedGetMediaAttachment ).toHaveBeenCalledWith( { id: 42 } );
79
+ expect( result ).toEqual( {
80
+ html: expect.stringContaining( 'fill="currentColor"' ),
81
+ url: 'https://example.com/resolved.svg',
82
+ } );
83
+ } );
84
+
85
+ it( 'falls back to provided url when attachment lookup fails', async () => {
86
+ // Arrange.
87
+ mockedGetMediaAttachment.mockResolvedValue( null as never );
88
+ mockFetch( SAMPLE_SVG );
89
+
90
+ // Act.
91
+ const result = ( await svgSrcTransformer(
92
+ { id: 99, url: 'https://example.com/fallback.svg' },
93
+ { key: 'svg' }
94
+ ) ) as { html: string; url: string };
95
+
96
+ // Assert.
97
+ expect( result.url ).toBe( 'https://example.com/fallback.svg' );
98
+ expect( result.html ).toContain( 'fill="currentColor"' );
99
+ } );
100
+
101
+ it( 'returns null html when id is set but attachment and url are missing', async () => {
102
+ // Arrange.
103
+ mockedGetMediaAttachment.mockResolvedValue( null as never );
104
+
105
+ // Act.
106
+ const result = await svgSrcTransformer( { id: 7, url: null }, { key: 'svg' } );
107
+
108
+ // Assert.
109
+ expect( result ).toEqual( { html: null, url: null } );
110
+ } );
111
+
112
+ it( 'returns null html when fetch fails', async () => {
113
+ // Arrange.
114
+ mockFetch( '', 'image/svg+xml', false );
115
+
116
+ // Act.
117
+ const result = await svgSrcTransformer( { id: null, url: 'https://example.com/missing.svg' }, { key: 'svg' } );
118
+
119
+ // Assert.
120
+ expect( result ).toEqual( {
121
+ html: null,
122
+ url: 'https://example.com/missing.svg',
123
+ } );
124
+ } );
125
+
126
+ it( 'returns null html when content is not SVG', async () => {
127
+ // Arrange.
128
+ mockFetch( '<html><body>Not SVG</body></html>', 'text/html' );
129
+
130
+ // Act.
131
+ const result = await svgSrcTransformer( { id: null, url: 'https://example.com/page.html' }, { key: 'svg' } );
132
+
133
+ // Assert.
134
+ expect( result ).toEqual( {
135
+ html: null,
136
+ url: 'https://example.com/page.html',
137
+ } );
138
+ } );
139
+
140
+ it( 'merges with existing style attribute', async () => {
141
+ // Arrange.
142
+ const svgWithStyle = '<svg xmlns="http://www.w3.org/2000/svg" style="display: block"><path d="M0 0"/></svg>';
143
+ mockFetch( svgWithStyle );
144
+
145
+ // Act.
146
+ const result = ( await svgSrcTransformer(
147
+ { id: null, url: 'https://example.com/styled.svg' },
148
+ { key: 'svg' }
149
+ ) ) as { html: string; url: string };
150
+
151
+ // Assert.
152
+ expect( result.html ).toContain( 'display: block' );
153
+ expect( result.html ).toContain( 'width: 100%' );
154
+ } );
155
+
156
+ it( 'returns null html when response has no svg element', async () => {
157
+ // Arrange.
158
+ mockFetch( '<div>Not an SVG</div>' );
159
+
160
+ // Act.
161
+ const result = await svgSrcTransformer( { id: null, url: 'https://example.com/not-svg.svg' }, { key: 'svg' } );
162
+
163
+ // Assert.
164
+ expect( result ).toEqual( {
165
+ html: null,
166
+ url: 'https://example.com/not-svg.svg',
167
+ } );
168
+ } );
169
+
170
+ it( 'passes abort signal to fetch', async () => {
171
+ // Arrange.
172
+ mockFetch( SAMPLE_SVG );
173
+ const controller = new AbortController();
174
+
175
+ // Act.
176
+ await svgSrcTransformer(
177
+ { id: null, url: 'https://example.com/icon.svg' },
178
+ { key: 'svg', signal: controller.signal }
179
+ );
180
+
181
+ // Assert.
182
+ expect( global.fetch ).toHaveBeenCalledWith( 'https://example.com/icon.svg', { signal: controller.signal } );
183
+ } );
184
+ } );
@@ -0,0 +1,87 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { getMediaAttachment } from '@elementor/wp-media';
3
+
4
+ import { createTransformer } from '../create-transformer';
5
+ import type { TransformerOptions } from '../types';
6
+
7
+ type SvgSrc = {
8
+ id?: unknown;
9
+ url?: unknown;
10
+ };
11
+
12
+ const SVG_INLINE_STYLES = 'width: 100%; height: 100%; overflow: unset;';
13
+
14
+ function processSvgContent( svgText: string ): string | null {
15
+ const sanitized = DOMPurify.sanitize( svgText, {
16
+ USE_PROFILES: { svg: true, svgFilters: true },
17
+ } );
18
+
19
+ const parser = new DOMParser();
20
+ const doc = parser.parseFromString( sanitized, 'image/svg+xml' );
21
+ const svgElement = doc.querySelector( 'svg' );
22
+
23
+ if ( ! svgElement ) {
24
+ return null;
25
+ }
26
+
27
+ svgElement.setAttribute( 'fill', 'currentColor' );
28
+
29
+ const existingStyle = svgElement.getAttribute( 'style' ) ?? '';
30
+ const trimmed = existingStyle.trim();
31
+ const merged = trimmed ? `${ trimmed.replace( /;$/, '' ) }; ${ SVG_INLINE_STYLES }` : SVG_INLINE_STYLES;
32
+ svgElement.setAttribute( 'style', merged );
33
+
34
+ return svgElement.outerHTML;
35
+ }
36
+
37
+ async function fetchSvgContent( url: string, signal?: AbortSignal ): Promise< string | null > {
38
+ try {
39
+ const response = await fetch( url, { signal } );
40
+
41
+ if ( ! response.ok ) {
42
+ return null;
43
+ }
44
+
45
+ const contentType = response.headers.get( 'content-type' ) ?? '';
46
+ const isSvg = contentType.includes( 'svg' ) || contentType.includes( 'xml' ) || url.endsWith( '.svg' );
47
+
48
+ if ( ! isSvg ) {
49
+ return null;
50
+ }
51
+
52
+ return await response.text();
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function resolveSvgSrcId( id: unknown ): number | null {
59
+ if ( typeof id !== 'number' || id <= 0 ) {
60
+ return null;
61
+ }
62
+
63
+ return id;
64
+ }
65
+
66
+ export const svgSrcTransformer = createTransformer( async ( value: SvgSrc, { signal }: TransformerOptions ) => {
67
+ const id = resolveSvgSrcId( value.id );
68
+ const urlFromValue = typeof value.url === 'string' ? value.url : null;
69
+
70
+ let url: string | null | undefined = urlFromValue;
71
+
72
+ if ( id && ! urlFromValue ) {
73
+ const attachment = await getMediaAttachment( { id } );
74
+ url = attachment?.url ?? null;
75
+ }
76
+
77
+ const resolvedUrl = typeof url === 'string' ? url : null;
78
+
79
+ if ( ! resolvedUrl ) {
80
+ return { html: null, url: null };
81
+ }
82
+
83
+ const svgText = await fetchSvgContent( resolvedUrl, signal );
84
+ const html = svgText ? processSvgContent( svgText ) : null;
85
+
86
+ return { html, url: resolvedUrl };
87
+ } );