@tiny-mce-umbraco/backoffice 17.5.0 → 17.6.2

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.
Files changed (29) hide show
  1. package/dist-cms/README.md +1 -0
  2. package/dist-cms/components/input-tiny-mce/input-tiny-mce.defaults.d.ts.map +1 -1
  3. package/dist-cms/components/input-tiny-mce/input-tiny-mce.defaults.js +103 -1
  4. package/dist-cms/components/input-tiny-mce/input-tiny-mce.element.d.ts +4 -1
  5. package/dist-cms/components/input-tiny-mce/input-tiny-mce.element.d.ts.map +1 -1
  6. package/dist-cms/components/input-tiny-mce/input-tiny-mce.element.js +97 -12
  7. package/dist-cms/components/input-tiny-mce/shadow-dom-selection.d.ts +16 -0
  8. package/dist-cms/components/input-tiny-mce/shadow-dom-selection.d.ts.map +1 -0
  9. package/dist-cms/components/input-tiny-mce/shadow-dom-selection.js +112 -0
  10. package/dist-cms/components/stylesheet-rule-input/stylesheet-rule-input.element.d.ts +1 -1
  11. package/dist-cms/entry-points/blob-download-router-fix.d.ts +56 -0
  12. package/dist-cms/entry-points/blob-download-router-fix.d.ts.map +1 -0
  13. package/dist-cms/entry-points/blob-download-router-fix.js +81 -0
  14. package/dist-cms/entry-points/entry-point.d.ts.map +1 -1
  15. package/dist-cms/entry-points/entry-point.js +7 -1
  16. package/dist-cms/plugins/manifests.d.ts.map +1 -1
  17. package/dist-cms/plugins/manifests.js +17 -0
  18. package/dist-cms/plugins/tiny-mce-mediapicker.plugin.d.ts.map +1 -1
  19. package/dist-cms/plugins/tiny-mce-mediapicker.plugin.js +122 -42
  20. package/dist-cms/property-editors/configEditor/property-editor-ui-tiny-mce-config-editor.element.d.ts.map +1 -1
  21. package/dist-cms/property-editors/configEditor/property-editor-ui-tiny-mce-config-editor.element.js +0 -1
  22. package/dist-cms/property-editors/manifests.js +3 -3
  23. package/dist-cms/property-editors/plugin/property-editor-ui-tiny-mce-plugin-configuration.element.d.ts.map +1 -1
  24. package/dist-cms/property-editors/plugin/property-editor-ui-tiny-mce-plugin-configuration.element.js +15 -5
  25. package/dist-cms/property-editors/tiny-mce/manifests.js +1 -1
  26. package/dist-cms/utils/index.d.ts +15 -5
  27. package/dist-cms/utils/index.d.ts.map +1 -1
  28. package/dist-cms/utils/index.js +27 -5
  29. package/package.json +4 -4
@@ -0,0 +1 @@
1
+ This is an Umbraco CMS package that enables access to the paid features in the TinyMCE-based Rich Text Editor (RTE) in version 12+ with a license. It also adds a new TinyMCE Premium property editor with additional settings, providing a more targeted configuration setup for RTE Data Types in Umbraco. Finally, it allows for additional settings that support JSON directly in the configuration for .NET (appsettings.config).
@@ -1 +1 @@
1
- {"version":3,"file":"input-tiny-mce.defaults.d.ts","sourceRoot":"","sources":["../../../src/components/input-tiny-mce/input-tiny-mce.defaults.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAKjF,eAAO,MAAM,4CAA4C,sCAAsC,CAAC;AAEhG,eAAO,MAAM,yBAAyB,uQAmB5B,CAAC;AAMX,eAAO,MAAM,qBAAqB,EAAE,gBAmOnC,CAAC"}
1
+ {"version":3,"file":"input-tiny-mce.defaults.d.ts","sourceRoot":"","sources":["../../../src/components/input-tiny-mce/input-tiny-mce.defaults.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAKjF,eAAO,MAAM,4CAA4C,sCAAsC,CAAC;AAEhG,eAAO,MAAM,yBAAyB,uQAmB5B,CAAC;AAMX,eAAO,MAAM,qBAAqB,EAAE,gBA0UnC,CAAC"}
@@ -59,6 +59,12 @@ export const defaultFallbackConfig = {
59
59
  // });
60
60
  //},
61
61
  init_instance_callback: function (editor) {
62
+ // Everything below bootstraps the editor's *iframe* realm through `editor.dom.doc.head`. Inline
63
+ // mode has no iframe and `editor.dom.doc` IS the backoffice document, so running any of it would
64
+ // inject a duplicate import map and re-register live manifests into the running registry - and
65
+ // none of it is needed there, since the components it bootstraps already live in this realm.
66
+ if (!editor.iframeElement)
67
+ return;
62
68
  // The following code is the context api proxy. [NL]
63
69
  // It re-dispatches the context api request event to the origin target of this modal, in other words the element that initiated the modal. [NL]
64
70
  editor.dom.doc.addEventListener(UMB_CONTEXT_REQUEST_EVENT_TYPE, ((event) => {
@@ -131,7 +137,25 @@ export const defaultFallbackConfig = {
131
137
  // instance) from the outer document. Without this, requestDelete() in UmbBlockEntryContext
132
138
  // falls back to returning raw keys because the iframe's manager has no registered translations.
133
139
  const locSetsToSync = [];
134
- const BLOCK_LOC_KEYS = ['blockEditor_confirmDeleteBlockTitle', 'blockEditor_confirmDeleteBlockMessage'];
140
+ // Keys needed by components that render INSIDE the TinyMCE iframe. The iframe has its own
141
+ // per-realm umbLocalizationManager, so anything not copied across renders blank there.
142
+ // The unsupported-block pair is read by Umbraco 17.6's <umb-unsupported-rte-block>.
143
+ const BLOCK_LOC_KEYS = [
144
+ // Rendered by the block components themselves.
145
+ 'blockEditor_confirmDeleteBlockTitle',
146
+ 'blockEditor_confirmDeleteBlockMessage',
147
+ 'blockEditor_unsupportedBlockName',
148
+ 'blockEditor_unsupportedBlockDescription',
149
+ // Labels declared by Umbraco's `blockAction` manifests, which `umb-block-action-list`
150
+ // renders inside the iframe. Enumerated from
151
+ // `packages/block/block/action/common/*/manifests.ts` — kept complete deliberately, so a
152
+ // newly reachable action does not show a raw `#key` the way copy-to-clipboard did.
153
+ 'clipboard_labelForCopyToClipboard',
154
+ 'general_delete',
155
+ 'general_edit',
156
+ 'general_settings',
157
+ 'actions_create',
158
+ ];
135
159
  umbLocalizationManager.localizations.forEach((locSet, code) => {
136
160
  const locSetAny = locSet;
137
161
  const entry = {
@@ -149,11 +173,42 @@ export const defaultFallbackConfig = {
149
173
  if (hasKey)
150
174
  locSetsToSync.push(entry);
151
175
  });
176
+ // The active language must cross the realm boundary too, not just the dictionaries.
177
+ // Without this the iframe resolves every term through UmbLocalizationController's
178
+ // primary -> secondary -> `en` fallback chain, so the synced Danish set sits in the
179
+ // iframe's map unused and the placeholder renders in English.
180
+ //
181
+ // This must go through `umbLocalizationRegistry.loadLanguage()`, not a direct write to
182
+ // `umbLocalizationManager.documentLanguage`. The registry is a module side-effect
183
+ // (`export const umbLocalizationRegistry = new UmbLocalizationRegistry(...)`), and the
184
+ // block script below imports `@umbraco-cms/backoffice/block-rte`, whose module graph pulls
185
+ // the localization package into the iframe realm. Constructing the registry there emits
186
+ // its `UMB_DEFAULT_LOCALIZATION_CULTURE` bootstrap value synchronously, whose `tap` stamps
187
+ // `'en'` onto the manager — overwriting a direct assignment. Both scripts are dynamically
188
+ // inserted and therefore `async` with no guaranteed order, and the block script's larger
189
+ // graph reliably lands last. Importing the registry here instead means it is constructed
190
+ // before this script's body runs, so `loadLanguage()` always wins regardless of order.
191
+ // Nothing inside the iframe ever calls `loadLanguage()` otherwise — that is driven by
192
+ // `<umb-app>` and the current-user context, neither of which exists in this realm.
193
+ const outerLang = umbLocalizationManager.documentLanguage;
194
+ const outerDir = umbLocalizationManager.documentDirection;
152
195
  if (locSetsToSync.length > 0) {
153
196
  const locScript = document.createElement('script');
154
197
  locScript.setAttribute('type', 'module');
155
198
  locScript.text = `
156
199
  import { umbLocalizationManager } from "@umbraco-cms/backoffice/localization-api";
200
+ import { umbLocalizationRegistry } from "@umbraco-cms/backoffice/localization";
201
+
202
+ // Sets documentLanguage via the registry's synchronous \`tap\`. The load that
203
+ // follows finds no localization extensions in this realm's registry, so the
204
+ // pipeline stops at its own length guard and never writes the language again.
205
+ umbLocalizationRegistry.loadLanguage(${JSON.stringify(outerLang)});
206
+ // Direction is only set by #setBrowserLanguage, which that stalled pipeline never
207
+ // reaches, so carry it across by hand — it matters for an RTL backoffice.
208
+ umbLocalizationManager.documentDirection = ${JSON.stringify(outerDir)};
209
+ // Register last: registerLocalization's keysChanged pass is what re-renders any
210
+ // consumer that connected before this script ran, and setting the language
211
+ // notifies nobody on its own.
157
212
  ${JSON.stringify(locSetsToSync)}.forEach(s => umbLocalizationManager.registerLocalization(s));
158
213
  `;
159
214
  editor.dom.doc.head.appendChild(locScript);
@@ -179,6 +234,13 @@ export const defaultFallbackConfig = {
179
234
  import { umbExtensionsRegistry } from "@umbraco-cms/backoffice/extension-registry";
180
235
  import { UMB_BLOCK_ACTION_DEFAULT_KIND_MANIFEST } from "@umbraco-cms/backoffice/block";
181
236
  import "${UMB_BLOCK_ENTRY_WEB_COMPONENTS_ABSOLUTE_PATH}";
237
+ // Defines <umb-icon>, which umb-ref-rte-block renders for the block's element-type icon.
238
+ // block-rte does not pull it in transitively, so without this it stays an un-upgraded
239
+ // unknown element: the .name property binding still lands, but nothing ever renders.
240
+ // The existing UUIIconRequestEvent proxy is not involved — uui-icon is already defined
241
+ // in this realm, and the action-bar icons resolve through that proxy correctly.
242
+ // This is the whole components barrel because the export map exposes no narrower path.
243
+ import "@umbraco-cms/backoffice/components";
182
244
 
183
245
  // Register the blockAction default kind definition in the inner realm's registry.
184
246
  // UMB_BLOCK_ACTION_DEFAULT_KIND_MANIFEST is imported here (inner realm), so its
@@ -195,6 +257,46 @@ export const defaultFallbackConfig = {
195
257
  if (outerReg) {
196
258
  const blockActions = outerReg.getByType('blockAction') ?? [];
197
259
  if (blockActions.length) umbExtensionsRegistry.registerMany(blockActions);
260
+
261
+ // Block actions declare their \`conditions\` by alias, and the extension system resolves
262
+ // each alias against the registry the action was registered in. Copying the actions
263
+ // without the condition manifests they name leaves every conditional action permanently
264
+ // un-initialized — delete, edit content, edit settings and expose content all have
265
+ // conditions, copy-to-clipboard does not, which is why it was the only one that rendered.
266
+ // Derived from the copied manifests rather than hard-coded, so an action that gains a
267
+ // new condition in a future Umbraco version is covered without another fix here.
268
+ const conditionAliases = new Set(
269
+ blockActions.flatMap((a) => (a.conditions ?? []).map((c) => c.alias)),
270
+ );
271
+ const conditions = (outerReg.getByType('condition') ?? []).filter((c) =>
272
+ conditionAliases.has(c.alias),
273
+ );
274
+ if (conditions.length) umbExtensionsRegistry.registerMany(conditions);
275
+ }
276
+
277
+ // Define the ufm-* elements in the inner realm so {=alias} block labels interpolate. Unlike
278
+ // umb-icon these are in no importable barrel - the outer document defines them by running
279
+ // each ufmComponent manifest's api(). Two constraints when editing this:
280
+ // 1. The manifests are on the package's \`manifests\` export, NOT \`extensions\`, which is a
281
+ // single bundle wrapper - filtering that matches nothing and fails silently.
282
+ // 2. This is the only top-level await in the script, so it must stay LAST or everything
283
+ // above it waits on the ufm package plus one dynamic import per component. Custom
284
+ // element upgrade is retroactive, so defining these last costs nothing.
285
+ try {
286
+ const ufmPkg = await import("/umbraco/backoffice/packages/ufm/umbraco-package.js");
287
+ const loaded = new Set();
288
+ for (const manifest of (ufmPkg.manifests ?? []).filter((m) => m.type === "ufmComponent")) {
289
+ if (loaded.has(manifest.alias)) continue;
290
+ loaded.add(manifest.alias);
291
+ try {
292
+ await manifest.api?.();
293
+ } catch {
294
+ // A single component failing to load should not break the rest.
295
+ }
296
+ }
297
+ } catch {
298
+ // If the ufm package is ever restructured, block labels degrade back to empty
299
+ // rather than breaking the editor.
198
300
  }
199
301
  `;
200
302
  editor.dom.doc.head.appendChild(script);
@@ -1,7 +1,8 @@
1
+ import type { PropertyValues } from '@umbraco-cms/backoffice/external/lit';
1
2
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
2
3
  import type { Editor } from '@tiny-mce-umbraco/backoffice/external/tinymce';
3
4
  import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
4
- declare const UmbInputTinyMceElement_base: (new (...args: any[]) => import("@umbraco-ui/uui-base").UUIFormControlMixinElement<FormDataEntryValue | FormData>) & typeof UmbLitElement;
5
+ declare const UmbInputTinyMceElement_base: (new (...args: any[]) => import("@umbraco-ui/uui").UUIFormControlWithBasicsMixinElement<FormDataEntryValue | FormData>) & typeof UmbLitElement;
5
6
  export declare class UmbInputTinyMceElement extends UmbInputTinyMceElement_base {
6
7
  #private;
7
8
  configuration?: UmbPropertyEditorConfigCollection;
@@ -17,7 +18,9 @@ export declare class UmbInputTinyMceElement extends UmbInputTinyMceElement_base
17
18
  get readonly(): boolean;
18
19
  set readonly(value: boolean);
19
20
  private readonly _editorElement?;
21
+ private _inline;
20
22
  getEditor(): Editor | null | undefined;
23
+ willUpdate(changedProperties: PropertyValues): void;
21
24
  firstUpdated(): void;
22
25
  disconnectedCallback(): void;
23
26
  getFormatStyles(stylesheetPaths: Array<string>): Promise<any[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"input-tiny-mce.element.d.ts","sourceRoot":"","sources":["../../../src/components/input-tiny-mce/input-tiny-mce.element.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,aAAa,EAAE,MAAM,qCAAqC,CAAC;AAIpE,OAAO,KAAK,EAAe,MAAM,EAAoB,MAAM,+CAA+C,CAAC;AAE3G,OAAO,KAAK,EAAE,iCAAiC,EAAE,MAAM,yCAAyC,CAAC;;AA8DjG,qBACa,sBAAuB,SAAQ,2BAAsC;;IAEjF,aAAa,CAAC,EAAE,iCAAiC,CAAC;cAS/B,cAAc;IAIjC,IAAa,KAAK,CAAC,QAAQ,EAAE,kBAAkB,GAAG,QAAQ,EAOzD;IAED,IAAa,KAAK,IAAI,kBAAkB,GAAG,QAAQ,CAElD;IAED;;;;;OAKG;IACH,IACW,QAAQ,YAElB;IACD,IAAW,QAAQ,CAAC,KAAK,SAAA,EAOxB;IAID,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAc;IAE9C,SAAS;IAIA,YAAY;IAsBZ,oBAAoB;IA0BvB,eAAe,CAAC,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC;IA+UpD;;;OAGG;IACM,MAAM;IAIf,gBAAyB,MAAM,4BAkB7B;CACF;AAED,eAAe,sBAAsB,CAAC;AAEtC,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,oBAAoB,EAAE,sBAAsB,CAAC;KAC7C;CACD"}
1
+ {"version":3,"file":"input-tiny-mce.element.d.ts","sourceRoot":"","sources":["../../../src/components/input-tiny-mce/input-tiny-mce.element.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAO3E,OAAO,EAAE,aAAa,EAAE,MAAM,qCAAqC,CAAC;AAKpE,OAAO,KAAK,EAAe,MAAM,EAAoB,MAAM,+CAA+C,CAAC;AAE3G,OAAO,KAAK,EAAE,iCAAiC,EAAE,MAAM,yCAAyC,CAAC;;AA6EjG,qBACa,sBAAuB,SAAQ,2BAAsC;;IAEjF,aAAa,CAAC,EAAE,iCAAiC,CAAC;cAe/B,cAAc;IAIjC,IAAa,KAAK,CAAC,QAAQ,EAAE,kBAAkB,GAAG,QAAQ,EAOzD;IAED,IAAa,KAAK,IAAI,kBAAkB,GAAG,QAAQ,CAElD;IAED;;;;;OAKG;IACH,IACW,QAAQ,YAElB;IACD,IAAW,QAAQ,CAAC,KAAK,SAAA,EAOxB;IAID,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAc;IAG9C,OAAO,CAAC,OAAO,CAAS;IAExB,SAAS;IAIA,UAAU,CAAC,iBAAiB,EAAE,cAAc;IAU5C,YAAY;IA+BZ,oBAAoB;IA0BvB,eAAe,CAAC,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC;IAiXpD;;;OAGG;IACM,MAAM;IAMf,gBAAyB,MAAM,4BAiC7B;CACF;AAED,eAAe,sBAAsB,CAAC;AAEtC,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,oBAAoB,EAAE,sBAAsB,CAAC;KAC7C;CACD"}
@@ -15,13 +15,13 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
15
15
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
16
16
  return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
17
17
  };
18
- var _UmbInputTinyMceElement_instances, _UmbInputTinyMceElement_plugins, _UmbInputTinyMceElement_editorRef, _UmbInputTinyMceElement_sanitizeTinyMce, _UmbInputTinyMceElement_stylesheetRepository, _UmbInputTinyMceElement_umbStylesheetRuleManager, _UmbInputTinyMceElement_readonly, _UmbInputTinyMceElement_loadEditor, _UmbInputTinyMceElement_loadPlugins, _UmbInputTinyMceElement_getTinyMceConfig, _UmbInputTinyMceElement_setTinyConfig, _UmbInputTinyMceElement_getLanguage, _UmbInputTinyMceElement_editorSetup, _UmbInputTinyMceElement_onInit, _UmbInputTinyMceElement_onChange;
18
+ var _UmbInputTinyMceElement_instances, _UmbInputTinyMceElement_plugins, _UmbInputTinyMceElement_editorRef, _UmbInputTinyMceElement_sanitizeTinyMce, _UmbInputTinyMceElement_stylesheetRootPath, _UmbInputTinyMceElement_stylesheetRepository, _UmbInputTinyMceElement_umbStylesheetRuleManager, _UmbInputTinyMceElement_readonly, _UmbInputTinyMceElement_loadStylesheetRootPath, _UmbInputTinyMceElement_loadEditor, _UmbInputTinyMceElement_loadPlugins, _UmbInputTinyMceElement_getTinyMceConfig, _UmbInputTinyMceElement_setTinyConfig, _UmbInputTinyMceElement_getLanguage, _UmbInputTinyMceElement_editorSetup, _UmbInputTinyMceElement_onInit, _UmbInputTinyMceElement_onChange;
19
19
  import { availableLanguages } from '@tiny-mce-umbraco/backoffice/core';
20
20
  import { defaultFallbackConfig, defaultPremiumPluginsList } from '@tiny-mce-umbraco/backoffice/core';
21
21
  import { pastePreProcessHandler } from '@tiny-mce-umbraco/backoffice/core';
22
22
  import { uriAttributeSanitizer } from '@tiny-mce-umbraco/backoffice/core';
23
23
  import { UmbStylesheetRuleManager } from '@tiny-mce-umbraco/backoffice/core';
24
- import { css, customElement, html, property, query } from '@umbraco-cms/backoffice/external/lit';
24
+ import { css, customElement, html, property, query, state } from '@umbraco-cms/backoffice/external/lit';
25
25
  import { loadManifestApi } from '@umbraco-cms/backoffice/extension-api';
26
26
  import { getProcessedImageUrl, umbDeepMerge } from '@umbraco-cms/backoffice/utils';
27
27
  import { renderEditor } from '@umbraco-cms/backoffice/external/tinymce';
@@ -30,7 +30,9 @@ import { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api
30
30
  import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
31
31
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
32
32
  import { UmbStylesheetDetailRepository } from '@umbraco-cms/backoffice/stylesheet';
33
+ import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server';
33
34
  import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui';
35
+ import { bridgeInlineEditor } from './shadow-dom-selection.js';
34
36
  import { TinyMceService } from '../../api/index.js';
35
37
  import { tryExecute } from '@umbraco-cms/backoffice/resources';
36
38
  // @ts-ignore
@@ -52,10 +54,24 @@ async function onResize(e) {
52
54
  });
53
55
  e.target.setAttribute('data-mce-src', resizedPath);
54
56
  }
57
+ // Merges plugin lists into the flat `string[]` TinyMCE expects, dropping anything that is not a
58
+ // string. That filtering is load-bearing, not defensive dressing: TinyMCE trims every plugin name with
59
+ // `String.replace` (`trim$4`, called from its plugin-list parser), so a single non-string entry throws
60
+ // during editor construction and the editor never renders at all.
61
+ //
62
+ // Non-strings do reach here from configuration. `Umbraco:CMS:RichTextEditor:CustomConfig` binds into an
63
+ // `IDictionary<string, object>`, so a value written as a real JSON array has no scalar to bind and
64
+ // arrives as an object rather than a list. The supported way to express a list there is a JSON *string*
65
+ // - `"plugins": "[\"fullscreen\"]"` - which `parseJsonStringValues` turns back into a real array.
55
66
  function mergeArrays(arr1, arr2) {
56
- const a1 = Array.isArray(arr1) ? arr1 : arr1 ? [arr1] : [];
57
- const a2 = Array.isArray(arr2) ? arr2 : arr2 ? [arr2] : [];
58
- return Array.from(new Set([...a1, ...a2]));
67
+ const toStringList = (value) => {
68
+ if (typeof value === 'string')
69
+ return [value];
70
+ if (!Array.isArray(value))
71
+ return [];
72
+ return value.filter((entry) => typeof entry === 'string');
73
+ };
74
+ return Array.from(new Set([...toStringList(arr1), ...toStringList(arr2)]));
59
75
  }
60
76
  // Some config paths (e.g. Umbraco:CMS:RichTextEditor:CustomConfig) can only store scalar string
61
77
  // values via the .NET config binder. Complex TinyMCE options like style_formats were historically
@@ -86,9 +102,15 @@ let UmbInputTinyMceElement = class UmbInputTinyMceElement extends UUIFormControl
86
102
  _UmbInputTinyMceElement_plugins.set(this, []);
87
103
  _UmbInputTinyMceElement_editorRef.set(this, null);
88
104
  _UmbInputTinyMceElement_sanitizeTinyMce.set(this, true);
105
+ /**
106
+ * Where the picked stylesheets are served from. Configurable as `Global:UmbracoCssPath`; `/css` is
107
+ * the fallback Umbraco itself uses when the server has not reported one yet.
108
+ */
109
+ _UmbInputTinyMceElement_stylesheetRootPath.set(this, '/css');
89
110
  _UmbInputTinyMceElement_stylesheetRepository.set(this, new UmbStylesheetDetailRepository(this));
90
111
  _UmbInputTinyMceElement_umbStylesheetRuleManager.set(this, new UmbStylesheetRuleManager());
91
112
  _UmbInputTinyMceElement_readonly.set(this, false);
113
+ this._inline = false;
92
114
  }
93
115
  getFormElement() {
94
116
  return this._editorElement?.querySelector('iframe') ?? undefined;
@@ -123,7 +145,16 @@ let UmbInputTinyMceElement = class UmbInputTinyMceElement extends UUIFormControl
123
145
  getEditor() {
124
146
  return __classPrivateFieldGet(this, _UmbInputTinyMceElement_editorRef, "f");
125
147
  }
148
+ willUpdate(changedProperties) {
149
+ super.willUpdate(changedProperties);
150
+ // Resolved before the first render so the target element carries the right classes from the
151
+ // start - TinyMCE takes it over during init and `render()` cannot usefully re-run after that.
152
+ if (changedProperties.has('configuration')) {
153
+ this._inline = this.configuration?.getValueByAlias('mode')?.toLocaleLowerCase() === 'inline';
154
+ }
155
+ }
126
156
  firstUpdated() {
157
+ __classPrivateFieldGet(this, _UmbInputTinyMceElement_instances, "m", _UmbInputTinyMceElement_loadStylesheetRootPath).call(this);
127
158
  __classPrivateFieldGet(this, _UmbInputTinyMceElement_instances, "m", _UmbInputTinyMceElement_loadEditor).call(this);
128
159
  }
129
160
  disconnectedCallback() {
@@ -177,16 +208,27 @@ let UmbInputTinyMceElement = class UmbInputTinyMceElement extends UUIFormControl
177
208
  * a target div and binds the RTE to that element
178
209
  */
179
210
  render() {
180
- return html `<div class="editor"></div>`;
211
+ // `umb-rte` stands in for `body_class`, which TinyMCE only applies to the iframe body - in
212
+ // inline mode this element *is* the editable body.
213
+ return html `<div class=${this._inline ? 'editor inline umb-rte' : 'editor'}></div>`;
181
214
  }
182
215
  };
183
216
  _UmbInputTinyMceElement_plugins = new WeakMap();
184
217
  _UmbInputTinyMceElement_editorRef = new WeakMap();
185
218
  _UmbInputTinyMceElement_sanitizeTinyMce = new WeakMap();
219
+ _UmbInputTinyMceElement_stylesheetRootPath = new WeakMap();
186
220
  _UmbInputTinyMceElement_stylesheetRepository = new WeakMap();
187
221
  _UmbInputTinyMceElement_umbStylesheetRuleManager = new WeakMap();
188
222
  _UmbInputTinyMceElement_readonly = new WeakMap();
189
223
  _UmbInputTinyMceElement_instances = new WeakSet();
224
+ _UmbInputTinyMceElement_loadStylesheetRootPath = function _UmbInputTinyMceElement_loadStylesheetRootPath() {
225
+ this.consumeContext(UMB_SERVER_CONTEXT, (serverContext) => {
226
+ this.observe(serverContext?.getServerConnection()?.umbracoCssPath, (umbracoCssPath) => {
227
+ if (umbracoCssPath)
228
+ __classPrivateFieldSet(this, _UmbInputTinyMceElement_stylesheetRootPath, umbracoCssPath, "f");
229
+ });
230
+ });
231
+ };
190
232
  _UmbInputTinyMceElement_loadEditor = async function _UmbInputTinyMceElement_loadEditor() {
191
233
  this.observe(umbExtensionsRegistry.byType('tinyMcePlugin'), async (manifests) => {
192
234
  __classPrivateFieldGet(this, _UmbInputTinyMceElement_plugins, "f").length = 0;
@@ -234,8 +276,16 @@ _UmbInputTinyMceElement_setTinyConfig = async function _UmbInputTinyMceElement_s
234
276
  const stylesheetPaths = this.configuration?.getValueByAlias('stylesheets') ?? [];
235
277
  const styleFormats = await this.getFormatStyles(stylesheetPaths);
236
278
  const preValueCustomConfig = this.configuration?.getValueByAlias('customConfig') ?? {};
237
- // Map the stylesheets with server url
238
- const stylesheets = stylesheetPaths?.map((stylesheetPath) => `/css${stylesheetPath.replace(/\\/g, '/')}`) ?? [];
279
+ // Map the stylesheets with server url, matching what Umbraco's own rich text editor does in
280
+ // `input-tiptap.element.ts`. Prefixing unconditionally is wrong in two cases: a path that is
281
+ // already rooted (older configurations stored the full `/css/foo.css`, which became
282
+ // `/css/css/foo.css` and 404'd), and an absolute URL to an external stylesheet.
283
+ const stylesheets = stylesheetPaths?.map((stylesheetPath) => {
284
+ const path = stylesheetPath.replace(/\\/g, '/');
285
+ return path.startsWith('http') || path.startsWith(__classPrivateFieldGet(this, _UmbInputTinyMceElement_stylesheetRootPath, "f"))
286
+ ? path
287
+ : `${__classPrivateFieldGet(this, _UmbInputTinyMceElement_stylesheetRootPath, "f")}${path}`;
288
+ }) ?? [];
239
289
  stylesheets.push('/umbraco/backoffice/css/rte-content.css');
240
290
  const appSettingsConfig = await __classPrivateFieldGet(this, _UmbInputTinyMceElement_instances, "m", _UmbInputTinyMceElement_getTinyMceConfig).call(this);
241
291
  let apiKey = 'no-origin';
@@ -300,9 +350,14 @@ _UmbInputTinyMceElement_setTinyConfig = async function _UmbInputTinyMceElement_s
300
350
  configurationOptions.toolbar = false;
301
351
  }
302
352
  // set the configured inline mode
303
- const mode = this.configuration?.getValueByAlias('mode');
304
- if (mode?.toLocaleLowerCase() === 'inline') {
353
+ if (this._inline) {
305
354
  configurationOptions.inline = true;
355
+ // Inline mode makes the target element itself the editable body, so there is no editor
356
+ // chrome to size and TinyMCE drops height/width on the floor. Left unset, the empty
357
+ // target div collapses to 0px and the editor is invisible until it happens to be
358
+ // clicked - so hand the sizing to our own CSS instead (see the `.editor.inline` rule).
359
+ delete configurationOptions.height;
360
+ delete configurationOptions.width;
306
361
  }
307
362
  // set the maximum image size
308
363
  const maxImageSize = this.configuration?.getValueByAlias('maxImageSize');
@@ -343,8 +398,15 @@ _UmbInputTinyMceElement_setTinyConfig = async function _UmbInputTinyMceElement_s
343
398
  }
344
399
  if (appSettingsConfig) {
345
400
  if (appSettingsConfig.richTextEditor) {
346
- const mergedPlugins = mergeArrays(appSettingsConfig.richTextEditor.plugins, config.plugins);
347
- config = umbDeepMerge(parseJsonStringValues(appSettingsConfig.richTextEditor.customConfig), config);
401
+ // Plugins reach this section from two places: the dedicated `Plugins` setting, and a
402
+ // `plugins` entry inside `CustomConfig`. Both must be folded in before the assignment
403
+ // below, which overwrites whatever the deep merge produced. Miss the CustomConfig one
404
+ // and a plugin configured only there is silently dropped - the toolbar still lists its
405
+ // button, because the merged `toolbar` does survive, but TinyMCE never loaded the
406
+ // plugin so nothing renders for it.
407
+ const richTextEditorCustomConfig = parseJsonStringValues(appSettingsConfig.richTextEditor.customConfig);
408
+ const mergedPlugins = mergeArrays(mergeArrays(appSettingsConfig.richTextEditor.plugins, richTextEditorCustomConfig['plugins']), config.plugins);
409
+ config = umbDeepMerge(richTextEditorCustomConfig, config);
348
410
  config.plugins = mergedPlugins;
349
411
  if (appSettingsConfig.richTextEditor.validElements.length > 0) {
350
412
  config.valid_elements = appSettingsConfig.richTextEditor.validElements;
@@ -446,6 +508,11 @@ _UmbInputTinyMceElement_editorSetup = async function _UmbInputTinyMceElement_edi
446
508
  //console.log('#editorSetup end');
447
509
  };
448
510
  _UmbInputTinyMceElement_onInit = function _UmbInputTinyMceElement_onInit(editor) {
511
+ // Inline mode leaves the editable element in the backoffice's shadow DOM, which TinyMCE's
512
+ // document-scoped selection and id lookups cannot reach - see `shadow-dom-selection.ts`.
513
+ if (this._inline) {
514
+ bridgeInlineEditor(editor);
515
+ }
449
516
  //enable browser based spell checking
450
517
  editor.getBody().setAttribute('spellcheck', 'true');
451
518
  uriAttributeSanitizer(editor);
@@ -470,6 +537,21 @@ UmbInputTinyMceElement.styles = [
470
537
  position: absolute;
471
538
  }
472
539
 
540
+ /* Inline mode edits this element directly - TinyMCE renders no chrome around it and
541
+ ignores the configured height, so it needs to look like a field on its own. */
542
+ .editor.inline {
543
+ box-sizing: border-box;
544
+ min-height: 100px;
545
+ padding: var(--uui-size-space-3, 9px);
546
+ background-color: var(--uui-color-surface, #fff);
547
+ border: var(--uui-input-border-width, 1px) solid var(--uui-input-border-color, var(--uui-color-border, #d8d7d9));
548
+ }
549
+
550
+ .editor.inline:focus {
551
+ outline: none;
552
+ border-color: var(--uui-color-focus, #3544b1);
553
+ }
554
+
473
555
  /* FIXME: Remove this workaround when https://github.com/tinymce/tinymce/issues/6431 has been fixed */
474
556
  .tox .tox-collection__item-label {
475
557
  line-height: 1 !important;
@@ -485,6 +567,9 @@ __decorate([
485
567
  __decorate([
486
568
  query('.editor', true)
487
569
  ], UmbInputTinyMceElement.prototype, "_editorElement", void 0);
570
+ __decorate([
571
+ state()
572
+ ], UmbInputTinyMceElement.prototype, "_inline", void 0);
488
573
  UmbInputTinyMceElement = __decorate([
489
574
  customElement('umb-input-tiny-mce')
490
575
  ], UmbInputTinyMceElement);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * TinyMCE's inline mode assumes its editable element is somewhere document-level APIs can reach. In the
3
+ * backoffice it sits some twenty shadow roots deep (`umb-input-tiny-mce` -> ... -> `umb-app`), so three
4
+ * of those APIs silently fail and are bridged here. Classic mode needs none of it: its editable body is
5
+ * a plain document body inside the editor's own iframe.
6
+ *
7
+ * Every failure mode below is silent - no exception, no stack trace. See **Inline Mode and Shadow DOM**
8
+ * in this project's CLAUDE.md for the symptoms and how to test them.
9
+ */
10
+ import type { Editor } from '@tiny-mce-umbraco/backoffice/external/tinymce';
11
+ /**
12
+ * Applies the shadow-DOM bridges to a freshly initialized inline editor. Call for inline editors only.
13
+ * @param editor The inline editor to bridge.
14
+ */
15
+ export declare function bridgeInlineEditor(editor: Editor): void;
16
+ //# sourceMappingURL=shadow-dom-selection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shadow-dom-selection.d.ts","sourceRoot":"","sources":["../../../src/components/input-tiny-mce/shadow-dom-selection.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,+CAA+C,CAAC;AAwG5E;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,QAUhD"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * TinyMCE's inline mode assumes its editable element is somewhere document-level APIs can reach. In the
3
+ * backoffice it sits some twenty shadow roots deep (`umb-input-tiny-mce` -> ... -> `umb-app`), so three
4
+ * of those APIs silently fail and are bridged here. Classic mode needs none of it: its editable body is
5
+ * a plain document body inside the editor's own iframe.
6
+ *
7
+ * Every failure mode below is silent - no exception, no stack trace. See **Inline Mode and Shadow DOM**
8
+ * in this project's CLAUDE.md for the symptoms and how to test them.
9
+ */
10
+ /** Editable elements of the live inline editors; read by both document-level bridges. */
11
+ const inlineBodies = new Set();
12
+ let installed = false;
13
+ /**
14
+ * Resolved per call, never cached from focus/blur events: TinyMCE moves the selection while its own
15
+ * focus bookkeeping is mid-flight, and a stale flag there sends `setRng` to the wrong selection. This is
16
+ * the same liveness check TinyMCE's own `hasInlineFocus` makes.
17
+ */
18
+ function getFocusedInlineBody() {
19
+ for (const body of inlineBodies) {
20
+ if (body.getRootNode().activeElement === body)
21
+ return body;
22
+ }
23
+ return null;
24
+ }
25
+ /**
26
+ * TinyMCE reads the caret through `win.getSelection()` (`getSel` in `Selection.getRng`), which in
27
+ * Chromium never descends into a shadow root; `ShadowRoot.getSelection()` does. Firefox is the reverse -
28
+ * see the note on the fallback below.
29
+ *
30
+ * Diverts only while one of our inline editors holds focus, so every other caller gets the native
31
+ * selection untouched.
32
+ */
33
+ function installShadowDomSelectionBridge() {
34
+ const nativeGetSelection = window.getSelection.bind(window);
35
+ window.getSelection = function () {
36
+ const body = getFocusedInlineBody();
37
+ if (!body)
38
+ return nativeGetSelection();
39
+ // `ShadowRoot.getSelection()` is Chromium-only. The fallback is not a safety net - it is the
40
+ // working path in Firefox, whose `window.getSelection()` already pierces shadow roots. Removing
41
+ // it breaks Firefox. (Safari has neither and is unsupported.)
42
+ const selection = body.getRootNode().getSelection?.();
43
+ return selection ?? nativeGetSelection();
44
+ };
45
+ }
46
+ /**
47
+ * `Selection.setRng` guards with `isValidRange` -> `isAttachedToDom` -> Sugar's `contains`, a plain
48
+ * `d1.contains(d2)` that does not cross shadow boundaries. Every range inside the editor is therefore
49
+ * judged detached, and `setRng` returns before it dispatches anything.
50
+ *
51
+ * Patched on the document object rather than `Node.prototype` because that is the exact call being
52
+ * made. Only ever turns `false` into `true`, and only for nodes inside a live inline editor.
53
+ */
54
+ function installShadowDomAttachmentBridge() {
55
+ const nativeContains = document.contains.bind(document);
56
+ document.contains = (node) => {
57
+ if (nativeContains(node))
58
+ return true;
59
+ if (!node)
60
+ return false;
61
+ for (const body of inlineBodies) {
62
+ if (body === node || body.contains(node))
63
+ return true;
64
+ }
65
+ return false;
66
+ };
67
+ }
68
+ /** Installs the document-level bridges, once per page. */
69
+ function installBridges() {
70
+ if (installed)
71
+ return;
72
+ installed = true;
73
+ installShadowDomSelectionBridge();
74
+ installShadowDomAttachmentBridge();
75
+ }
76
+ /**
77
+ * `DOMUtils.get(id)` is `doc.getElementById(id)`, which cannot see into a shadow root. TinyMCE's
78
+ * bookmarks depend on it: operations that restructure the DOM drop `<span data-mce-type="bookmark">`
79
+ * markers and call `moveToBookmark` to restore the caret afterwards. Unresolvable markers mean the caret
80
+ * is never restored *and* the markers are never removed - they accumulate in the content and get saved.
81
+ *
82
+ * Searching the editor body first also scopes ids per editor rather than first-match-in-document.
83
+ * @param editor The inline editor to patch.
84
+ */
85
+ function bridgeEditorLookups(editor) {
86
+ const dom = editor.dom;
87
+ const nativeGet = dom.get.bind(dom);
88
+ dom.get = (elm) => {
89
+ if (typeof elm !== 'string')
90
+ return nativeGet(elm);
91
+ const body = editor.getBody();
92
+ if (!body)
93
+ return nativeGet(elm);
94
+ // Inline mode's body is the target element itself, so it can be the id being asked for.
95
+ if (body.id === elm)
96
+ return body;
97
+ return body.querySelector(`#${CSS.escape(elm)}`) ?? nativeGet(elm);
98
+ };
99
+ }
100
+ /**
101
+ * Applies the shadow-DOM bridges to a freshly initialized inline editor. Call for inline editors only.
102
+ * @param editor The inline editor to bridge.
103
+ */
104
+ export function bridgeInlineEditor(editor) {
105
+ const body = editor.getBody();
106
+ if (!body)
107
+ return;
108
+ bridgeEditorLookups(editor);
109
+ inlineBodies.add(body);
110
+ installBridges();
111
+ editor.on('remove', () => inlineBodies.delete(body));
112
+ }
@@ -1,6 +1,6 @@
1
1
  import type { UmbStylesheetRule } from '../../types.js';
2
2
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
3
- declare const UmbStylesheetRuleInputElement_base: (new (...args: any[]) => import("@umbraco-ui/uui-base").UUIFormControlMixinElement<FormDataEntryValue | FormData>) & typeof UmbLitElement;
3
+ declare const UmbStylesheetRuleInputElement_base: (new (...args: any[]) => import("@umbraco-ui/uui").UUIFormControlWithBasicsMixinElement<FormDataEntryValue | FormData>) & typeof UmbLitElement;
4
4
  export declare class UmbStylesheetRuleInputElement extends UmbStylesheetRuleInputElement_base {
5
5
  #private;
6
6
  rules: UmbStylesheetRule[];
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Workaround: let blob:/data: downloads through Umbraco's router.
3
+ *
4
+ * ## The problem
5
+ *
6
+ * Umbraco's router (`router-slot`) installs a global click listener that converts any same-origin
7
+ * anchor click into a history navigation — see
8
+ * `packages/core/router/router-slot/util/anchor.ts` (`ensureAnchorHistory`):
9
+ *
10
+ * ```js
11
+ * fullUrl = new URL(href, document.baseURI);
12
+ * if (fullUrl.origin !== location.origin || ...) return;
13
+ * e.preventDefault();
14
+ * history.pushState(null, '', fullUrl);
15
+ * ```
16
+ *
17
+ * Blob URLs inherit the origin of the document that created them, so
18
+ * `new URL('blob:https://site:44308/<uuid>').origin` is `https://site:44308` — the origin guard
19
+ * passes and the anchor is treated as an internal navigation. The router then:
20
+ *
21
+ * 1. calls `e.preventDefault()`, which **cancels the download**, and
22
+ * 2. calls `history.pushState` with a `blob:` URL, which is invalid and throws
23
+ * `SecurityError: A history state object with URL 'blob:…' cannot be created in a document
24
+ * with origin '…'`.
25
+ *
26
+ * The net effect is that any download served from a `blob:` or `data:` URL inside the backoffice
27
+ * silently fails. TinyMCE's `export` plugin (PDF export via `clientpdf`) hits this on every
28
+ * download attempt.
29
+ *
30
+ * ## The fix
31
+ *
32
+ * The router deliberately skips anchors carrying `data-router-slot="disabled"`. We cannot set that
33
+ * at creation time because the anchor is created inside TinyMCE's minified plugin bundle, so we tag
34
+ * it in flight instead: this listener is registered in the **capture** phase on `window`, which runs
35
+ * before the router's own bubble-phase listener on that same target. By the time the router sees the
36
+ * event, the anchor is marked and the router leaves it alone.
37
+ *
38
+ * Only `blob:` and `data:` are tagged. Ordinary links are untouched, so in-app navigation is
39
+ * unaffected.
40
+ *
41
+ * ## Upstream
42
+ *
43
+ * This is arguably an Umbraco defect: the router should not attempt to route non-`http(s)` schemes
44
+ * at all, which would also cover `mailto:` and `tel:`. It affects any blob-based download in the
45
+ * backoffice, not only TinyMCE. Verified present in Umbraco 17.6.2, and `anchor.ts` is byte-identical
46
+ * back to at least 17.5.0 — so this is long-standing, not a regression. Remove this workaround once
47
+ * the router gains a scheme guard.
48
+ */
49
+ /**
50
+ * Starts tagging `blob:`/`data:` anchors so Umbraco's router does not cancel the download.
51
+ * Safe to call more than once; only the first call registers a listener.
52
+ */
53
+ export declare function enableBlobDownloadWorkaround(): void;
54
+ /** Removes the listener registered by {@link enableBlobDownloadWorkaround}. */
55
+ export declare function disableBlobDownloadWorkaround(): void;
56
+ //# sourceMappingURL=blob-download-router-fix.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blob-download-router-fix.d.ts","sourceRoot":"","sources":["../../src/entry-points/blob-download-router-fix.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAqBH;;;GAGG;AACH,wBAAgB,4BAA4B,IAAI,IAAI,CAKnD;AAED,+EAA+E;AAC/E,wBAAgB,6BAA6B,IAAI,IAAI,CAIpD"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Workaround: let blob:/data: downloads through Umbraco's router.
3
+ *
4
+ * ## The problem
5
+ *
6
+ * Umbraco's router (`router-slot`) installs a global click listener that converts any same-origin
7
+ * anchor click into a history navigation — see
8
+ * `packages/core/router/router-slot/util/anchor.ts` (`ensureAnchorHistory`):
9
+ *
10
+ * ```js
11
+ * fullUrl = new URL(href, document.baseURI);
12
+ * if (fullUrl.origin !== location.origin || ...) return;
13
+ * e.preventDefault();
14
+ * history.pushState(null, '', fullUrl);
15
+ * ```
16
+ *
17
+ * Blob URLs inherit the origin of the document that created them, so
18
+ * `new URL('blob:https://site:44308/<uuid>').origin` is `https://site:44308` — the origin guard
19
+ * passes and the anchor is treated as an internal navigation. The router then:
20
+ *
21
+ * 1. calls `e.preventDefault()`, which **cancels the download**, and
22
+ * 2. calls `history.pushState` with a `blob:` URL, which is invalid and throws
23
+ * `SecurityError: A history state object with URL 'blob:…' cannot be created in a document
24
+ * with origin '…'`.
25
+ *
26
+ * The net effect is that any download served from a `blob:` or `data:` URL inside the backoffice
27
+ * silently fails. TinyMCE's `export` plugin (PDF export via `clientpdf`) hits this on every
28
+ * download attempt.
29
+ *
30
+ * ## The fix
31
+ *
32
+ * The router deliberately skips anchors carrying `data-router-slot="disabled"`. We cannot set that
33
+ * at creation time because the anchor is created inside TinyMCE's minified plugin bundle, so we tag
34
+ * it in flight instead: this listener is registered in the **capture** phase on `window`, which runs
35
+ * before the router's own bubble-phase listener on that same target. By the time the router sees the
36
+ * event, the anchor is marked and the router leaves it alone.
37
+ *
38
+ * Only `blob:` and `data:` are tagged. Ordinary links are untouched, so in-app navigation is
39
+ * unaffected.
40
+ *
41
+ * ## Upstream
42
+ *
43
+ * This is arguably an Umbraco defect: the router should not attempt to route non-`http(s)` schemes
44
+ * at all, which would also cover `mailto:` and `tel:`. It affects any blob-based download in the
45
+ * backoffice, not only TinyMCE. Verified present in Umbraco 17.6.2, and `anchor.ts` is byte-identical
46
+ * back to at least 17.5.0 — so this is long-standing, not a regression. Remove this workaround once
47
+ * the router gains a scheme guard.
48
+ */
49
+ /** Schemes that must never be handed to `history.pushState`. */
50
+ const NON_ROUTABLE_SCHEME = /^(?:blob|data):/i;
51
+ let isListening = false;
52
+ const onClickCapture = (event) => {
53
+ // composedPath() is required to see through shadow boundaries — the same approach the
54
+ // router itself uses.
55
+ const anchor = event
56
+ .composedPath()
57
+ .find((target) => target instanceof HTMLAnchorElement);
58
+ if (!anchor || !NON_ROUTABLE_SCHEME.test(anchor.href))
59
+ return;
60
+ // Opt this anchor out of router-slot handling. The router reads
61
+ // `$anchor.dataset['routerSlot'] === 'disabled'` and returns early.
62
+ anchor.dataset['routerSlot'] = 'disabled';
63
+ };
64
+ /**
65
+ * Starts tagging `blob:`/`data:` anchors so Umbraco's router does not cancel the download.
66
+ * Safe to call more than once; only the first call registers a listener.
67
+ */
68
+ export function enableBlobDownloadWorkaround() {
69
+ if (isListening)
70
+ return;
71
+ // Capture phase, so this runs before router-slot's bubble-phase listener on `window`.
72
+ window.addEventListener('click', onClickCapture, true);
73
+ isListening = true;
74
+ }
75
+ /** Removes the listener registered by {@link enableBlobDownloadWorkaround}. */
76
+ export function disableBlobDownloadWorkaround() {
77
+ if (!isListening)
78
+ return;
79
+ window.removeEventListener('click', onClickCapture, true);
80
+ isListening = false;
81
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"entry-point.d.ts","sourceRoot":"","sources":["../../src/entry-points/entry-point.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAExG,eAAO,MAAM,MAAM,EAAE,mBAuBpB,CAAC;AAEF,eAAO,MAAM,QAAQ,EAAE,qBAAyD,CAAC"}
1
+ {"version":3,"file":"entry-point.d.ts","sourceRoot":"","sources":["../../src/entry-points/entry-point.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAExG,eAAO,MAAM,MAAM,EAAE,mBA2BpB,CAAC;AAEF,eAAO,MAAM,QAAQ,EAAE,qBAEtB,CAAC"}
@@ -1,6 +1,10 @@
1
1
  import { client } from '../api/client.gen.js';
2
+ import { disableBlobDownloadWorkaround, enableBlobDownloadWorkaround, } from './blob-download-router-fix.js';
2
3
  import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
3
4
  export const onInit = (_host, _extensionRegistry) => {
5
+ // Let blob:/data: downloads (e.g. TinyMCE's PDF export) through Umbraco's router.
6
+ // See blob-download-router-fix.ts for why this is needed.
7
+ enableBlobDownloadWorkaround();
4
8
  // Will use only to add in Open API config with generated TS OpenAPI HTTPS Client
5
9
  // Do the OAuth token handshake stuff
6
10
  _host.consumeContext(UMB_AUTH_CONTEXT, async (authContext) => {
@@ -22,4 +26,6 @@ export const onInit = (_host, _extensionRegistry) => {
22
26
  });
23
27
  });
24
28
  };
25
- export const onUnload = (_host, _extensionRegistry) => { };
29
+ export const onUnload = (_host, _extensionRegistry) => {
30
+ disableBlobDownloadWorkaround();
31
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"manifests.d.ts","sourceRoot":"","sources":["../../src/plugins/manifests.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mCAAmC,CAAC;AAE/E,eAAO,MAAM,SAAS,EAAE,KAAK,CAAC,qBAAqB,CA0flD,CAAC"}
1
+ {"version":3,"file":"manifests.d.ts","sourceRoot":"","sources":["../../src/plugins/manifests.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mCAAmC,CAAC;AAE/E,eAAO,MAAM,SAAS,EAAE,KAAK,CAAC,qBAAqB,CA2gBlD,CAAC"}
@@ -136,6 +136,23 @@ export const manifests = [
136
136
  ],
137
137
  },
138
138
  },
139
+ {
140
+ type: 'tinyMcePlugin',
141
+ alias: 'Umb.TinyMcePlugin.Fullscreen',
142
+ name: 'TinyMCE Fullscreen Plugin',
143
+ meta: {
144
+ plugins: ['fullscreen'],
145
+ toolbar: [
146
+ {
147
+ alias: 'fullscreen',
148
+ label: 'Fullscreen',
149
+ icon: 'fullscreen',
150
+ isplugin: true,
151
+ pluginAlias: 'fullscreen',
152
+ },
153
+ ],
154
+ },
155
+ },
139
156
  {
140
157
  type: 'tinyMcePlugin',
141
158
  alias: 'Umb.TinyMcePlugin.Help',
@@ -1 +1 @@
1
- {"version":3,"file":"tiny-mce-mediapicker.plugin.d.ts","sourceRoot":"","sources":["../../src/plugins/tiny-mce-mediapicker.plugin.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AACzE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mCAAmC,CAAC;AA0BhF,MAAM,CAAC,OAAO,OAAO,2BAA4B,SAAQ,oBAAoB;;gBAQhE,IAAI,EAAE,sBAAsB;CAsOxC"}
1
+ {"version":3,"file":"tiny-mce-mediapicker.plugin.d.ts","sourceRoot":"","sources":["../../src/plugins/tiny-mce-mediapicker.plugin.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AACzE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mCAAmC,CAAC;AAyChF,MAAM,CAAC,OAAO,OAAO,2BAA4B,SAAQ,oBAAoB;;gBAQhE,IAAI,EAAE,sBAAsB;CA2VxC"}
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _UmbTinyMceMediaPickerPlugin_instances, _UmbTinyMceMediaPickerPlugin_modalManager, _UmbTinyMceMediaPickerPlugin_temporaryFileRepository, _UmbTinyMceMediaPickerPlugin_allowedMediaTypeIds_get, _UmbTinyMceMediaPickerPlugin_onAction, _UmbTinyMceMediaPickerPlugin_showMediaPicker, _UmbTinyMceMediaPickerPlugin_showMediaCaptionAltText, _UmbTinyMceMediaPickerPlugin_insertInEditor, _UmbTinyMceMediaPickerPlugin_uploadImageHandler;
12
+ var _UmbTinyMceMediaPickerPlugin_instances, _UmbTinyMceMediaPickerPlugin_modalManager, _UmbTinyMceMediaPickerPlugin_temporaryFileRepository, _UmbTinyMceMediaPickerPlugin_allowedMediaTypeIds_get, _UmbTinyMceMediaPickerPlugin_resolveExistingMedia, _UmbTinyMceMediaPickerPlugin_onAction, _UmbTinyMceMediaPickerPlugin_parseDimension, _UmbTinyMceMediaPickerPlugin_showMediaPicker, _UmbTinyMceMediaPickerPlugin_showMediaCaptionAltText, _UmbTinyMceMediaPickerPlugin_insertInEditor, _UmbTinyMceMediaPickerPlugin_uploadImageHandler;
13
13
  import { sizeImageInEditor, uploadBlobImages } from '@tiny-mce-umbraco/backoffice/core';
14
14
  import { UmbTinyMcePluginBase } from '@tiny-mce-umbraco/backoffice/core';
15
15
  import { getGuidFromUdi, splitStringToArray } from '@umbraco-cms/backoffice/utils';
@@ -62,8 +62,14 @@ class UmbTinyMceMediaPickerPlugin extends UmbTinyMcePluginBase {
62
62
  tooltip: localize.term('general_mediaPicker'),
63
63
  onAction: () => __classPrivateFieldGet(this, _UmbTinyMceMediaPickerPlugin_instances, "m", _UmbTinyMceMediaPickerPlugin_onAction).call(this),
64
64
  onSetup: (api) => {
65
- const changed = this.editor.selection.selectorChangedWithUnbind('img[data-udi]', (state) => api.setActive(state));
66
- return () => changed.unbind();
65
+ // Drive the active state from the SAME resolution the action uses. A selector of
66
+ // `img[data-udi]` only lights up when the image itself is selected, so with the caret in
67
+ // the figcaption the button looked like "nothing selected" while the action would happily
68
+ // have edited that image. Button state and action behaviour must agree.
69
+ const syncActiveState = () => api.setActive(!!__classPrivateFieldGet(this, _UmbTinyMceMediaPickerPlugin_instances, "m", _UmbTinyMceMediaPickerPlugin_resolveExistingMedia).call(this).image);
70
+ this.editor.on('NodeChange', syncActiveState);
71
+ syncActiveState();
72
+ return () => this.editor.off('NodeChange', syncActiveState);
67
73
  },
68
74
  });
69
75
  // Register global options for the editor
@@ -88,37 +94,49 @@ class UmbTinyMceMediaPickerPlugin extends UmbTinyMcePluginBase {
88
94
  }
89
95
  _UmbTinyMceMediaPickerPlugin_modalManager = new WeakMap(), _UmbTinyMceMediaPickerPlugin_temporaryFileRepository = new WeakMap(), _UmbTinyMceMediaPickerPlugin_uploadImageHandler = new WeakMap(), _UmbTinyMceMediaPickerPlugin_instances = new WeakSet(), _UmbTinyMceMediaPickerPlugin_allowedMediaTypeIds_get = function _UmbTinyMceMediaPickerPlugin_allowedMediaTypeIds_get() {
90
96
  return splitStringToArray(this.configuration?.getValueByAlias('allowedMediaTypes'));
91
- }, _UmbTinyMceMediaPickerPlugin_onAction =
92
- /*
93
- async #observeCurrentUser() {
94
- if (!this.#currentUserContext) return;
95
-
96
- this.observe(this.#currentUserContext.currentUser, (currentUser) => (this.#currentUser = currentUser));
97
- }
98
- */
99
- async function _UmbTinyMceMediaPickerPlugin_onAction() {
100
- const selectedElm = this.editor.selection.getNode();
97
+ }, _UmbTinyMceMediaPickerPlugin_resolveExistingMedia = function _UmbTinyMceMediaPickerPlugin_resolveExistingMedia() {
98
+ const dom = this.editor.dom;
99
+ const selectedNode = this.editor.selection.getNode();
100
+ const figure = dom.getParent(selectedNode, 'figure');
101
+ const image = selectedNode.nodeName === 'IMG'
102
+ ? selectedNode
103
+ : (figure?.querySelector('img') ?? null);
104
+ const anchor = image ? dom.getParent(image, 'a') : null;
105
+ // The `<figcaption>` is what the editor actually sees and edits, so it wins over the
106
+ // `data-caption` attribute, which is only written when the picker last ran and goes stale as
107
+ // soon as someone edits the caption directly in the editor.
108
+ const figcaptionText = figure?.querySelector('figcaption')?.textContent?.trim();
109
+ const caption = figcaptionText || image?.dataset.caption || undefined;
110
+ return { selectedNode, figure, image, anchor, caption };
111
+ }, _UmbTinyMceMediaPickerPlugin_onAction = async function _UmbTinyMceMediaPickerPlugin_onAction() {
112
+ const { image, caption } = __classPrivateFieldGet(this, _UmbTinyMceMediaPickerPlugin_instances, "m", _UmbTinyMceMediaPickerPlugin_resolveExistingMedia).call(this);
101
113
  let currentTarget = {};
102
- if (selectedElm.nodeName === 'IMG') {
103
- const img = selectedElm;
104
- const hasUdi = img.hasAttribute('data-udi');
105
- const hasDataTmpImg = img.hasAttribute('data-tmpimg');
114
+ if (image) {
106
115
  currentTarget = {
107
- altText: img.alt,
108
- url: img.src,
109
- caption: img.dataset.caption,
116
+ altText: image.alt,
117
+ url: image.src,
118
+ caption,
119
+ // Carry the current dimensions in so the dialog opens showing the real size rather
120
+ // than defaulting, and so an unchanged dialog round-trips the size unchanged.
121
+ width: __classPrivateFieldGet(this, _UmbTinyMceMediaPickerPlugin_instances, "m", _UmbTinyMceMediaPickerPlugin_parseDimension).call(this, image.getAttribute('width')),
122
+ height: __classPrivateFieldGet(this, _UmbTinyMceMediaPickerPlugin_instances, "m", _UmbTinyMceMediaPickerPlugin_parseDimension).call(this, image.getAttribute('height')),
110
123
  };
111
- if (hasUdi) {
112
- currentTarget['udi'] = img.dataset.udi;
124
+ if (image.hasAttribute('data-udi')) {
125
+ currentTarget['udi'] = image.dataset.udi;
113
126
  }
114
127
  else {
115
- currentTarget['id'] = img.getAttribute('rel') ?? undefined;
128
+ currentTarget['id'] = image.getAttribute('rel') ?? undefined;
116
129
  }
117
- if (hasDataTmpImg) {
118
- currentTarget['tmpimg'] = img.dataset.tmpimg;
130
+ if (image.hasAttribute('data-tmpimg')) {
131
+ currentTarget['tmpimg'] = image.dataset.tmpimg;
119
132
  }
120
133
  }
121
134
  __classPrivateFieldGet(this, _UmbTinyMceMediaPickerPlugin_instances, "m", _UmbTinyMceMediaPickerPlugin_showMediaPicker).call(this, currentTarget);
135
+ }, _UmbTinyMceMediaPickerPlugin_parseDimension = function _UmbTinyMceMediaPickerPlugin_parseDimension(value) {
136
+ if (!value)
137
+ return undefined;
138
+ const parsed = Number.parseInt(value, 10);
139
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
122
140
  }, _UmbTinyMceMediaPickerPlugin_showMediaPicker = async function _UmbTinyMceMediaPickerPlugin_showMediaPicker(currentTarget) {
123
141
  /*
124
142
  let startNodeId;
@@ -156,12 +174,20 @@ async function _UmbTinyMceMediaPickerPlugin_onAction() {
156
174
  if (!mediaUnique)
157
175
  return;
158
176
  const modalHandler = __classPrivateFieldGet(this, _UmbTinyMceMediaPickerPlugin_modalManager, "f")?.open(this, UMB_MEDIA_CAPTION_ALT_TEXT_MODAL, {
159
- data: { mediaUnique },
177
+ // The dialog needs maxImageSize to offer sensible sizes; without it the size it hands back
178
+ // is unconstrained and we have no idea what the editor's limit was meant to be.
179
+ data: { mediaUnique, maxImageSize: this.editor.options.get('maxImageSize') },
160
180
  value: {
161
181
  url: '',
162
182
  altText: currentTarget.altText,
163
183
  caption: currentTarget.caption,
184
+ width: currentTarget.width,
185
+ height: currentTarget.height,
164
186
  },
187
+ // The token defaults this sidebar to `small`, which is too narrow for the width/height row —
188
+ // the unit suffix on the height field gets clipped. Umbraco's own tiptap RTE overrides it the
189
+ // same way for the same reason ("Override default sidebar size for better UX").
190
+ modal: { size: 'medium' },
165
191
  });
166
192
  const mediaData = await modalHandler?.onSubmit().catch(() => null);
167
193
  if (!mediaData)
@@ -171,6 +197,9 @@ async function _UmbTinyMceMediaPickerPlugin_onAction() {
171
197
  caption: mediaData?.caption,
172
198
  url: mediaData?.url,
173
199
  udi: 'umb://media/' + mediaUnique?.replace(/-/g, ''),
200
+ // The dialog is the authority on size once the editor has chosen one.
201
+ width: mediaData?.width,
202
+ height: mediaData?.height,
174
203
  };
175
204
  __classPrivateFieldGet(this, _UmbTinyMceMediaPickerPlugin_instances, "m", _UmbTinyMceMediaPickerPlugin_insertInEditor).call(this, media);
176
205
  }, _UmbTinyMceMediaPickerPlugin_insertInEditor = async function _UmbTinyMceMediaPickerPlugin_insertInEditor(media) {
@@ -178,32 +207,78 @@ async function _UmbTinyMceMediaPickerPlugin_onAction() {
178
207
  return;
179
208
  // We need to create a NEW DOM <img> element to insert
180
209
  // setting an attribute of ID to __mcenew, so we can gather a reference to the node, to be able to update its size accordingly to the size of the image.
210
+ // An explicit size from the dialog is authoritative. Stamping it on the markup up front means
211
+ // the editor never briefly renders at the wrong size, and it tells the post-insert sizing step
212
+ // below to leave the dimensions alone.
213
+ const explicitSize = media.width && media.height ? { width: media.width, height: media.height } : undefined;
181
214
  const img = {
182
215
  alt: media.altText,
183
216
  src: media.url ? media.url : 'nothing.jpg',
184
217
  id: '__mcenew',
185
218
  'data-udi': media.udi,
186
219
  'data-caption': media.caption,
220
+ ...(explicitSize
221
+ ? { width: String(explicitSize.width), height: String(explicitSize.height) }
222
+ : {}),
187
223
  };
188
- const newImage = this.editor.dom.createHTML('img', img);
189
- const parentElement = this.editor.selection.getNode().parentElement;
190
- if (img['data-caption'] && parentElement) {
191
- const figCaption = this.editor.dom.createHTML('figcaption', {}, img['data-caption']);
192
- const combined = newImage + figCaption;
193
- if (parentElement.nodeName !== 'FIGURE') {
194
- const fragment = this.editor.dom.createHTML('figure', {}, combined);
195
- this.editor.selection.setContent(fragment);
196
- }
197
- else {
198
- parentElement.innerHTML = combined;
224
+ const dom = this.editor.dom;
225
+ // Same resolution the edit path used, so both agree on what is being replaced. The caret may
226
+ // be on the image, on an <a> wrapping it, or inside the figcaption.
227
+ //
228
+ // The anchor matters because Umbraco fixed this same class of bug in its own RTE in 17.6
229
+ // (media-picker.tiptap-toolbar.utils.ts); re-picking used to silently drop the <a>.
230
+ const { selectedNode, figure: existingFigure, image: existingImage, anchor: existingAnchor, } = __classPrivateFieldGet(this, _UmbTinyMceMediaPickerPlugin_instances, "m", _UmbTinyMceMediaPickerPlugin_resolveExistingMedia).call(this);
231
+ let markup = dom.createHTML('img', img);
232
+ if (existingAnchor) {
233
+ // Rebuild the wrapping link. `data-mce-*` attributes are TinyMCE's own bookkeeping and are
234
+ // skipped so they get regenerated rather than staling.
235
+ //
236
+ // Because the new markup now CONTAINS the link, whatever we replace below must be the
237
+ // element the old link lived in — never the image inside it. Replacing just the image would
238
+ // nest an <a> inside an <a>; the parser rejects that and splits it, leaving an empty
239
+ // duplicate link behind.
240
+ const anchorAttrs = {};
241
+ for (const attr of Array.from(existingAnchor.attributes)) {
242
+ if (!attr.name.startsWith('data-mce-')) {
243
+ anchorAttrs[attr.name] = attr.value;
244
+ }
199
245
  }
246
+ markup = dom.createHTML('a', anchorAttrs, markup);
200
247
  }
201
- else if (parentElement?.nodeName === 'FIGURE' && parentElement.parentElement) {
202
- //if caption is removed, remove the figure element
203
- parentElement.parentElement.innerHTML = newImage;
248
+ const caption = img['data-caption'];
249
+ if (caption && existingFigure) {
250
+ // Replace only the figure's children, so the figure's own attributes survive along with
251
+ // every sibling elsewhere in the container.
252
+ existingFigure.innerHTML = markup + dom.createHTML('figcaption', {}, caption);
204
253
  }
205
254
  else {
206
- this.editor.selection.setContent(newImage);
255
+ const content = caption ? dom.createHTML('figure', {}, markup + dom.createHTML('figcaption', {}, caption)) : markup;
256
+ // Replace the OUTERMOST element whose markup we have just rebuilt: the figure if there was
257
+ // one, otherwise the link, otherwise the image. Anything narrower would leave a fragment of
258
+ // the old structure behind; anything wider would take out unrelated siblings — the bug in
259
+ // the previous implementation, which assigned to `figure.parentElement.innerHTML`.
260
+ let replaceTarget = existingFigure ?? existingAnchor ?? existingImage;
261
+ if (caption && !existingFigure) {
262
+ // A <figure> is block-level and cannot legally sit inside a <p>. Replacing something
263
+ // *within* the paragraph makes the parser split it, leaving an empty <p> either side of
264
+ // the figure. So when the paragraph holds nothing but this image, promote the target to
265
+ // the paragraph and consume it.
266
+ //
267
+ // Guarded on the paragraph having no text of its own and at most one image, so a figure
268
+ // never swallows a paragraph that has other content in it. `length <= 1` rather than
269
+ // `=== 1` because a brand new insert at a caret has no image in the paragraph yet.
270
+ const paragraph = dom.getParent(replaceTarget ?? selectedNode, 'p');
271
+ if (paragraph && !paragraph.textContent?.trim() && paragraph.querySelectorAll('img').length <= 1) {
272
+ replaceTarget = paragraph;
273
+ }
274
+ }
275
+ if (replaceTarget) {
276
+ dom.setOuterHTML(replaceTarget, content);
277
+ }
278
+ else {
279
+ // Nothing existing to replace: a brand new insert at the caret.
280
+ this.editor.selection.setContent(content);
281
+ }
207
282
  }
208
283
  // Using settimeout to wait for a DoM-render, so we can find the new element by ID.
209
284
  setTimeout(() => {
@@ -212,8 +287,13 @@ async function _UmbTinyMceMediaPickerPlugin_onAction() {
212
287
  return;
213
288
  this.editor.dom.setAttrib(imgElm, 'id', null);
214
289
  // When image is loaded we are ready to call sizeImageInEditor.
290
+ //
291
+ // `sizeImageInEditor` reads the image's natural size and scales it down to maxImageSize,
292
+ // overwriting the width/height attributes. That is right for pasted or dragged images, but
293
+ // it used to silently discard whatever size the editor chose in the dialog. Passing the
294
+ // explicit size keeps it — the sizer then only refreshes the processed image URL.
215
295
  const onImageLoaded = () => {
216
- sizeImageInEditor(this.editor, imgElm, img.src);
296
+ sizeImageInEditor(this.editor, imgElm, img.src, explicitSize);
217
297
  this.editor.dispatch('Change');
218
298
  };
219
299
  // Check if image already is loaded.
@@ -1 +1 @@
1
- {"version":3,"file":"property-editor-ui-tiny-mce-config-editor.element.d.ts","sourceRoot":"","sources":["../../../src/property-editors/configEditor/property-editor-ui-tiny-mce-config-editor.element.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,qCAAqC,CAAC;AACpE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AAC7E,OAAO,KAAK,EACX,0BAA0B,EAC1B,iCAAiC,EACjC,MAAM,yCAAyC,CAAC;AAKjD;;GAEG;AACH,qBACa,6CAA8C,SAAQ,aAAc,YAAW,0BAA0B;;IAErH,IACI,KAAK,CAAC,KAAK,EAAE,IAAI,GAAG,IAAI,EAI3B;IACD,IAAI,KAAK,IAAI,IAAI,CAEhB;IAGD,MAAM,CAAC,EAAE,iCAAiC,CAAC;IAG3C,OAAO,CAAC,aAAa,CAA0B;cAEtB,YAAY,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,OAAO,CAAC;IAqC1E,MAAM;IASf,gBAAyB,MAAM,4BAY7B;CACF;AAED,eAAe,6CAA6C,CAAC;AAE7D,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,+CAA+C,EAAE,6CAA6C,CAAC;KAC/F;CACD"}
1
+ {"version":3,"file":"property-editor-ui-tiny-mce-config-editor.element.d.ts","sourceRoot":"","sources":["../../../src/property-editors/configEditor/property-editor-ui-tiny-mce-config-editor.element.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,qCAAqC,CAAC;AACpE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AAC7E,OAAO,KAAK,EACX,0BAA0B,EAC1B,iCAAiC,EACjC,MAAM,yCAAyC,CAAC;AAKjD;;GAEG;AACH,qBACa,6CAA8C,SAAQ,aAAc,YAAW,0BAA0B;;IAErH,IACI,KAAK,CAAC,KAAK,EAAE,IAAI,GAAG,IAAI,EAI3B;IACD,IAAI,KAAK,IAAI,IAAI,CAEhB;IAGD,MAAM,CAAC,EAAE,iCAAiC,CAAC;IAG3C,OAAO,CAAC,aAAa,CAA0B;cAEtB,YAAY,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,OAAO,CAAC;IAoC1E,MAAM;IASf,gBAAyB,MAAM,4BAY7B;CACF;AAED,eAAe,6CAA6C,CAAC;AAE7D,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,+CAA+C,EAAE,6CAA6C,CAAC;KAC/F;CACD"}
@@ -64,7 +64,6 @@ _UmbPropertyEditorUITinyMceConfigEditorElement_showCodeEditor = async function _
64
64
  else {
65
65
  try {
66
66
  this._customConfig = JSON.parse(value.content.toString());
67
- console.log('Parsed object:', this._customConfig);
68
67
  // Proceed with using jsonObj
69
68
  }
70
69
  catch (err) {
@@ -9,7 +9,7 @@ export const manifests = [
9
9
  meta: {
10
10
  label: 'TinyMCE Plugin Configuration',
11
11
  icon: 'icon-autofill',
12
- group: 'common',
12
+ group: '#propertyEditorUIGroups_common',
13
13
  },
14
14
  },
15
15
  {
@@ -20,7 +20,7 @@ export const manifests = [
20
20
  meta: {
21
21
  label: 'TinyMCE Toolbar Configuration',
22
22
  icon: 'icon-autofill',
23
- group: 'common',
23
+ group: '#propertyEditorUIGroups_common',
24
24
  },
25
25
  },
26
26
  {
@@ -31,7 +31,7 @@ export const manifests = [
31
31
  meta: {
32
32
  label: 'TinyMCE Custom Configuration',
33
33
  icon: 'icon-autofill',
34
- group: 'common',
34
+ group: '#propertyEditorUIGroups_common',
35
35
  },
36
36
  },
37
37
  ];
@@ -1 +1 @@
1
- {"version":3,"file":"property-editor-ui-tiny-mce-plugin-configuration.element.d.ts","sourceRoot":"","sources":["../../../src/property-editors/plugin/property-editor-ui-tiny-mce-plugin-configuration.element.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,aAAa,EAAE,MAAM,qCAAqC,CAAC;AAEpE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AAC7E,OAAO,KAAK,EACX,0BAA0B,EAC1B,iCAAiC,EACjC,MAAM,yCAAyC,CAAC;AAejD;;GAEG;AACH,qBACa,oDACZ,SAAQ,aACR,YAAW,0BAA0B;;IAErC,IACI,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,EAexC;IACD,IAAI,KAAK,IAAI,MAAM,EAAE,CAEpB;IAGD,MAAM,CAAC,EAAE,iCAAiC,CAAC;IAG3C,OAAO,CAAC,aAAa,CAAsB;cAIlB,YAAY,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,OAAO,CAAC;YAuBrE,UAAU;IA4FxB,OAAO,CAAC,QAAQ;IAaP,MAAM;IAoBf,gBAAyB,MAAM,4BAS7B;CACF;AAED,eAAe,oDAAoD,CAAC;AAEpE,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,sDAAsD,EAAE,oDAAoD,CAAC;KAC7G;CACD"}
1
+ {"version":3,"file":"property-editor-ui-tiny-mce-plugin-configuration.element.d.ts","sourceRoot":"","sources":["../../../src/property-editors/plugin/property-editor-ui-tiny-mce-plugin-configuration.element.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,aAAa,EAAE,MAAM,qCAAqC,CAAC;AAEpE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AAC7E,OAAO,KAAK,EACX,0BAA0B,EAC1B,iCAAiC,EACjC,MAAM,yCAAyC,CAAC;AAejD;;GAEG;AACH,qBACa,oDACZ,SAAQ,aACR,YAAW,0BAA0B;;IAErC,IACI,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,EAexC;IACD,IAAI,KAAK,IAAI,MAAM,EAAE,CAEpB;IAGD,MAAM,CAAC,EAAE,iCAAiC,CAAC;IAG3C,OAAO,CAAC,aAAa,CAAsB;cAIlB,YAAY,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,OAAO,CAAC;YAuBrE,UAAU;IAqGxB,OAAO,CAAC,QAAQ;IAaP,MAAM;IAoBf,gBAAyB,MAAM,4BAS7B;CACF;AAED,eAAe,oDAAoD,CAAC;AAEpE,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,sDAAsD,EAAE,oDAAoD,CAAC;KAC7G;CACD"}
@@ -114,10 +114,20 @@ let UmbPropertyEditorUITinyMcePluginConfigurationElement = class UmbPropertyEdit
114
114
  });
115
115
  });
116
116
  }
117
- const defaultPluginAliases = this._pluginConfig.map((p) => p.alias);
117
+ // A plugin can be named by more than one source - the fallback defaults, the appsettings
118
+ // `Plugins` list, and a `tinyMcePlugin` manifest. Each source used to push its own row, so a
119
+ // plugin named twice appeared twice in the picker and handed lit's keyed `repeat()` a duplicate
120
+ // key. First source wins, which keeps the defaults' "(default plugin)" label and disabled state.
121
+ const seen = new Set(this._pluginConfig.map((p) => p.alias));
122
+ const addPlugin = (entry) => {
123
+ if (seen.has(entry.alias))
124
+ return;
125
+ seen.add(entry.alias);
126
+ this._pluginConfig.push(entry);
127
+ };
118
128
  configPlugins.forEach((p) => {
119
- if (!excludeList.includes(p) && !defaultPluginAliases.includes(p)) {
120
- this._pluginConfig.push({
129
+ if (!excludeList.includes(p)) {
130
+ addPlugin({
121
131
  alias: p,
122
132
  label: p,
123
133
  icon: undefined,
@@ -129,7 +139,7 @@ let UmbPropertyEditorUITinyMcePluginConfigurationElement = class UmbPropertyEdit
129
139
  plugins.forEach((p) => {
130
140
  if (p.meta?.plugins) {
131
141
  if (typeof p.meta.plugins === 'string' && !excludeList.includes(p.meta.plugins)) {
132
- this._pluginConfig.push({
142
+ addPlugin({
133
143
  alias: p.meta.plugins,
134
144
  label: p.meta.plugins,
135
145
  icon: undefined,
@@ -140,7 +150,7 @@ let UmbPropertyEditorUITinyMcePluginConfigurationElement = class UmbPropertyEdit
140
150
  else if (Array.isArray(p.meta.plugins)) {
141
151
  p.meta.plugins.forEach((pl) => {
142
152
  if (!excludeList.includes(pl)) {
143
- this._pluginConfig.push({
153
+ addPlugin({
144
154
  alias: pl,
145
155
  label: pl,
146
156
  icon: undefined,
@@ -17,7 +17,7 @@ export const manifests = [
17
17
  label: 'Rich Text Editor [TinyMCE]',
18
18
  propertyEditorSchemaAlias: UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS,
19
19
  icon: 'icon-tinymce',
20
- group: 'richContent',
20
+ group: '#propertyEditorUIGroups_richContent',
21
21
  supportsReadOnly: true,
22
22
  keywords: ['content', 'article', 'body', 'html', 'wysiwyg', 'richtext', 'rte', 'tinymce', 'editor', 'formatted', 'paragraph'],
23
23
  settings: {
@@ -1,11 +1,21 @@
1
1
  import type { Editor } from '@umbraco-cms/backoffice/external/tinymce';
2
2
  /**
3
- * Sizes an image in the editor
4
- * @param editor
5
- * @param imageDomElement
6
- * @param imgUrl
3
+ * Sizes an image in the editor.
4
+ *
5
+ * By default the image is scaled down to the editor's `maxImageSize`, which is what pasted and
6
+ * dragged images need. Pass `explicitSize` when the size has already been chosen deliberately (for
7
+ * example in the media picker's caption/alt-text dialog) — those dimensions are then used as-is and
8
+ * only the processed image URL is refreshed. Without it, a deliberately chosen size is silently
9
+ * overwritten by the max-size scaling.
10
+ * @param editor The TinyMCE editor instance.
11
+ * @param imageDomElement The image element to size.
12
+ * @param imgUrl Source URL, used to generate a resized `data-mce-src`.
13
+ * @param explicitSize Dimensions to apply verbatim instead of scaling to `maxImageSize`.
7
14
  */
8
- export declare function sizeImageInEditor(editor: Editor, imageDomElement: HTMLElement, imgUrl?: string): Promise<void>;
15
+ export declare function sizeImageInEditor(editor: Editor, imageDomElement: HTMLElement, imgUrl?: string, explicitSize?: {
16
+ width: number;
17
+ height: number;
18
+ }): Promise<void>;
9
19
  /**
10
20
  * Scales an image to the max size
11
21
  * @param maxSize
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0CAA0C,CAAC;AAEvE;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,CAAC,EAAE,MAAM,iBAsBpG;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;;;EA4B5E;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,iBAqDzE;AAED,OAAO,EAAE,wBAAwB,EAAE,MAAM,2CAA2C,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0CAA0C,CAAC;AAEvE;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACtC,MAAM,EAAE,MAAM,EACd,eAAe,EAAE,WAAW,EAC5B,MAAM,CAAC,EAAE,MAAM,EACf,YAAY,CAAC,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,iBA0ChD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;;;EA4B5E;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,iBAqDzE;AAED,OAAO,EAAE,wBAAwB,EAAE,MAAM,2CAA2C,CAAC"}
@@ -3,14 +3,36 @@
3
3
  // TODO: This should not be done in this way, we need to split this into separate defined helper methods. This is also very specific to TinyMCE, so should be named that way. [NL]
4
4
  import { getProcessedImageUrl } from '@umbraco-cms/backoffice/utils';
5
5
  /**
6
- * Sizes an image in the editor
7
- * @param editor
8
- * @param imageDomElement
9
- * @param imgUrl
6
+ * Sizes an image in the editor.
7
+ *
8
+ * By default the image is scaled down to the editor's `maxImageSize`, which is what pasted and
9
+ * dragged images need. Pass `explicitSize` when the size has already been chosen deliberately (for
10
+ * example in the media picker's caption/alt-text dialog) — those dimensions are then used as-is and
11
+ * only the processed image URL is refreshed. Without it, a deliberately chosen size is silently
12
+ * overwritten by the max-size scaling.
13
+ * @param editor The TinyMCE editor instance.
14
+ * @param imageDomElement The image element to size.
15
+ * @param imgUrl Source URL, used to generate a resized `data-mce-src`.
16
+ * @param explicitSize Dimensions to apply verbatim instead of scaling to `maxImageSize`.
10
17
  */
11
- export async function sizeImageInEditor(editor, imageDomElement, imgUrl) {
18
+ export async function sizeImageInEditor(editor, imageDomElement, imgUrl, explicitSize) {
12
19
  const size = editor.dom.getSize(imageDomElement);
13
20
  const maxImageSize = editor.options.get('maxImageSize');
21
+ if (explicitSize) {
22
+ editor.dom.setAttribs(imageDomElement, {
23
+ width: Math.round(explicitSize.width),
24
+ height: Math.round(explicitSize.height),
25
+ });
26
+ if (imgUrl) {
27
+ const resizedImgUrl = await getProcessedImageUrl(imgUrl, {
28
+ width: Math.round(explicitSize.width),
29
+ height: Math.round(explicitSize.height),
30
+ });
31
+ editor.dom.setAttrib(imageDomElement, 'data-mce-src', resizedImgUrl);
32
+ }
33
+ editor.execCommand('mceAutoResize', false);
34
+ return;
35
+ }
14
36
  if (maxImageSize && maxImageSize > 0) {
15
37
  const newSize = scaleToMaxSize(maxImageSize, size.w, size.h);
16
38
  editor.dom.setAttribs(imageDomElement, { width: Math.round(newSize.width), height: Math.round(newSize.height) });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiny-mce-umbraco/backoffice",
3
- "version": "17.5.0",
4
- "description": "This Umbraco CMS package enables access to the TinyMCE-based Rich Text Editor (RTE) in version 16 and above. It also supports the use of TinyMCE Premium plugins with a valid subscription. Additional features include streamlined configuration for RTE Data Types in Umbraco and enhanced settings that support direct JSON-based configuration via .NET (appsettings.config).",
3
+ "version": "17.6.2",
4
+ "description": "This Umbraco CMS package enables access to the TinyMCE-based Rich Text Editor (RTE). It requires Umbraco 17.6.2 or later - for Umbraco 16 use the 16.x package versions, for Umbraco 18 use 18.x. It also supports the use of TinyMCE Premium plugins with a valid subscription. Additional features include streamlined configuration for RTE Data Types in Umbraco and enhanced settings that support direct JSON-based configuration via .NET (appsettings.config).",
5
5
  "keywords": [
6
6
  "Umbraco",
7
7
  "TinyMCE",
@@ -40,7 +40,7 @@
40
40
  "@rollup/plugin-json": "^6.1.0",
41
41
  "@rollup/plugin-node-resolve": "^16.0.1",
42
42
  "@rollup/plugin-replace": "^6.0.2",
43
- "@umbraco-cms/backoffice": "^17.5.0",
43
+ "@umbraco-cms/backoffice": "^17.6.2",
44
44
  "chalk": "^5.3.0",
45
45
  "cross-env": "^10.1.0",
46
46
  "eslint": "^10.2.0",
@@ -55,7 +55,7 @@
55
55
  "vite-tsconfig-paths": "^5.1.4"
56
56
  },
57
57
  "peerDependencies": {
58
- "@umbraco-cms/backoffice": "^17.5.0",
58
+ "@umbraco-cms/backoffice": "^17.6.2",
59
59
  "tinymce": "^6.8.5",
60
60
  "tinymce-i18n": "^24.12.30"
61
61
  }