@notum-cz/strapi-plugin-tiptap-editor 1.1.1 → 1.2.1

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/README.md CHANGED
@@ -90,6 +90,7 @@
90
90
  - [Text Alignment](#text-alignment)
91
91
  - [Text Color \& Highlight Color](#text-color--highlight-color)
92
92
  - [Images](#images)
93
+ - [Rendering images on the frontend](#rendering-images-on-the-frontend)
93
94
  - [Theme](#theme)
94
95
  - [Colors](#colors)
95
96
  - [Custom Stylesheet](#custom-stylesheet)
@@ -415,18 +416,22 @@ Both features use a color picker popover that displays the colors defined in the
415
416
 
416
417
  ### Images
417
418
 
418
- | Key | Description | Toolbar |
419
- | -------------- | -------------------------------- | ------------------------------------------- |
420
- | `mediaLibrary` | Images from Strapi Media Library | Image button + alt text popover + alignment |
419
+ | Key | Description | Toolbar |
420
+ | -------------- | -------------------------------- | --------------------------------------------------------- |
421
+ | `mediaLibrary` | Images from Strapi Media Library | Image button + alt text popover + alignment + resize handle |
421
422
 
422
423
  Enables image insertion from the Strapi Media Library. When enabled, the toolbar shows an image button that opens the Media Library picker. After selecting an image:
423
424
 
424
425
  - The image appears in the editor at its natural size (constrained to editor width)
425
426
  - Alt text is prefilled from the asset's `alternativeText` metadata
426
- - Clicking a selected image opens a popover to edit alt text or delete the image
427
- - Three alignment buttons (left, center, right) allow repositioning the image
427
+ - Clicking a selected image opens a popover with:
428
+ - Three **alignment** buttons (left, center, right)
429
+ - **Width** and **Height** inputs (in pixels) for precise sizing
430
+ - A **reset** button to restore the original dimensions
431
+ - **Alt text** input and a **delete** button
432
+ - A **resize handle** (blue dot) appears at the bottom-right corner on hover — drag it to resize the image
428
433
 
429
- The image stores both the URL (`src`) and the Strapi asset ID (`data-asset-id`) in the Tiptap JSON output.
434
+ The image stores the URL (`src`), Strapi asset ID (`data-asset-id`), alignment (`data-align`), and dimensions (`width`, `height`) in the Tiptap JSON output.
430
435
 
431
436
  **Content safety:** If you remove `mediaLibrary` from a preset, existing images in content are preserved and rendered read-only — they are never silently deleted.
432
437
 
@@ -436,6 +441,73 @@ The image stores both the URL (`src`) and the Strapi asset ID (`data-asset-id`)
436
441
  }
437
442
  ```
438
443
 
444
+ **Resize** is configured through the `resize` key inside `mediaLibrary`. The options match the standard `@tiptap/extension-image` `resize` configuration. When `resize` is omitted or set to `false`, the resize handle and dimension controls are hidden.
445
+
446
+ ```ts
447
+ {
448
+ mediaLibrary: {
449
+ resize: {
450
+ enabled: true,
451
+ alwaysPreserveAspectRatio: true,
452
+ minWidth: 50,
453
+ minHeight: 50,
454
+ },
455
+ },
456
+ }
457
+ ```
458
+
459
+ | Option | Default | Description |
460
+ | ----------------------------------- | ------- | ----------------------------------------------------- |
461
+ | `resize` | _none_ | Set to `false` or omit to disable resize entirely |
462
+ | `resize.enabled` | `true` | Enable or disable resize when the object is present |
463
+ | `resize.alwaysPreserveAspectRatio` | `true` | Lock aspect ratio when resizing or editing dimensions |
464
+ | `resize.minWidth` | `50` | Minimum allowed width in pixels |
465
+ | `resize.minHeight` | `50` | Minimum allowed height in pixels |
466
+
467
+ #### Rendering images on the frontend
468
+
469
+ The plugin stores content as **Tiptap/ProseMirror JSON**. The `width`, `height`, `src`, `alt`, and `title` attributes are standard and will render automatically with `@tiptap/extension-image`. However, the custom `data-align` and `data-asset-id` attributes require extending the Image extension on your frontend:
470
+
471
+ ```ts
472
+ import { generateHTML } from '@tiptap/html';
473
+ import StarterKit from '@tiptap/starter-kit';
474
+ import Image from '@tiptap/extension-image';
475
+
476
+ // Extend with the custom attributes used by this plugin
477
+ const StrapiImage = Image.extend({
478
+ addAttributes() {
479
+ return {
480
+ ...this.parent?.(),
481
+ 'data-align': { default: null },
482
+ 'data-asset-id': { default: null },
483
+ };
484
+ },
485
+ });
486
+
487
+ // Convert the JSON from the Strapi API to HTML
488
+ const html = generateHTML(apiResponse.content, [
489
+ StarterKit,
490
+ StrapiImage,
491
+ // ...other extensions you use
492
+ ]);
493
+ ```
494
+
495
+ Then add CSS for alignment on your frontend:
496
+
497
+ ```css
498
+ img[data-align="center"] {
499
+ display: block;
500
+ margin-left: auto;
501
+ margin-right: auto;
502
+ }
503
+
504
+ img[data-align="right"] {
505
+ display: block;
506
+ margin-left: auto;
507
+ margin-right: 0;
508
+ }
509
+ ```
510
+
439
511
  ## Theme
440
512
 
441
513
  The `theme` key in the plugin config lets you define colors for the color pickers and inject custom CSS into the editor.
@@ -577,8 +649,10 @@ export default () => ({
577
649
  textColor: true,
578
650
  highlightColor: true,
579
651
 
580
- // Images from Strapi Media Library
581
- mediaLibrary: true,
652
+ // Images from Strapi Media Library with resize enabled
653
+ mediaLibrary: {
654
+ resize: { enabled: true },
655
+ },
582
656
  },
583
657
  },
584
658
  },
@@ -8,7 +8,7 @@ const ReactDOM = require("react-dom");
8
8
  const styled = require("styled-components");
9
9
  const admin = require("@strapi/strapi/admin");
10
10
  const icons = require("@strapi/icons");
11
- const index = require("./index-yXpX_VsO.js");
11
+ const index = require("./index-CTcKIvYv.js");
12
12
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
13
13
  const React__default = /* @__PURE__ */ _interopDefault(React);
14
14
  const ReactDOM__default = /* @__PURE__ */ _interopDefault(ReactDOM);
@@ -19909,19 +19909,53 @@ const TiptapInputStyles = styled__default.default.div`
19909
19909
  margin: 0.75em 0;
19910
19910
  }
19911
19911
 
19912
- /* Image alignment — margin-based, no float */
19913
- [data-align="center"] img {
19914
- margin-left: auto;
19915
- margin-right: auto;
19912
+ /* Image alignment — text-align on the node wrapper centres the inline-block image container */
19913
+ [data-align="center"] {
19914
+ text-align: center;
19916
19915
  }
19917
19916
 
19918
- [data-align="right"] img {
19919
- margin-left: auto;
19920
- margin-right: 0;
19917
+ [data-align="right"] {
19918
+ text-align: right;
19921
19919
  }
19922
19920
 
19923
19921
  /* data-align="left" and null: natural left flow, no rule needed */
19924
19922
 
19923
+ /* Inline-block wrapper for positioning the resize handle relative to the image */
19924
+ .image-wrapper {
19925
+ position: relative;
19926
+ display: inline-block;
19927
+ max-width: 100%;
19928
+ line-height: 0; /* prevent extra space below img */
19929
+ }
19930
+
19931
+ .image-wrapper img {
19932
+ display: block;
19933
+ max-width: 100%;
19934
+ height: auto;
19935
+ }
19936
+
19937
+ /* Resize handle — bottom-right corner */
19938
+ .image-resize-handle {
19939
+ position: absolute;
19940
+ right: -4px;
19941
+ bottom: -4px;
19942
+ width: 12px;
19943
+ height: 12px;
19944
+ background: #4945ff;
19945
+ border: 2px solid #fff;
19946
+ border-radius: 50%;
19947
+ cursor: nwse-resize;
19948
+ opacity: 0;
19949
+ transition: opacity 0.15s;
19950
+ z-index: 1;
19951
+ }
19952
+
19953
+ .image-wrapper:hover .image-resize-handle,
19954
+ .image-wrapper[data-selected] .image-resize-handle,
19955
+ .ProseMirror-selectednode .image-resize-handle {
19956
+ opacity: 1;
19957
+ }
19958
+
19925
19959
  // Source: https://tiptap.dev/docs/editor/extensions/nodes/table
19926
19960
 
19927
19961
  .ProseMirror {
@@ -20893,19 +20927,43 @@ function TextAlignRight(props) {
20893
20927
  }
20894
20928
  );
20895
20929
  }
20896
- function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode: deleteNode2 }) {
20930
+ function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode: deleteNode2, selected, extension }) {
20897
20931
  const { formatMessage } = reactIntl.useIntl();
20898
20932
  const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
20899
20933
  const [altText, setAltText] = React.useState(node.attrs.alt ?? "");
20934
+ const imgRef = React.useRef(null);
20935
+ const isResizingRef = React.useRef(false);
20936
+ const resizeCleanupRef = React.useRef(null);
20937
+ const rawResize = extension.options.resize;
20938
+ const resizeEnabled = typeof rawResize === "object" && !!rawResize ? rawResize.enabled : !!rawResize;
20939
+ const resizeOpts = resizeEnabled && typeof rawResize === "object" ? rawResize : void 0;
20940
+ const preserveAspectRatio = resizeOpts?.alwaysPreserveAspectRatio ?? true;
20941
+ const minWidth = resizeOpts?.minWidth ?? 50;
20942
+ const minHeight = resizeOpts?.minHeight ?? 50;
20943
+ const currentWidth = node.attrs.width;
20944
+ const currentHeight = node.attrs.height;
20945
+ const [widthInput, setWidthInput] = React.useState(currentWidth ? String(currentWidth) : "");
20946
+ const [heightInput, setHeightInput] = React.useState(currentHeight ? String(currentHeight) : "");
20900
20947
  React.useEffect(() => {
20901
20948
  setAltText(node.attrs.alt ?? "");
20902
20949
  }, [node.attrs.alt]);
20950
+ React.useEffect(() => {
20951
+ setWidthInput(node.attrs.width ? String(node.attrs.width) : "");
20952
+ }, [node.attrs.width]);
20953
+ React.useEffect(() => {
20954
+ setHeightInput(node.attrs.height ? String(node.attrs.height) : "");
20955
+ }, [node.attrs.height]);
20903
20956
  React.useEffect(() => {
20904
20957
  if (!isPopoverOpen) return;
20905
20958
  const handleScroll = () => setIsPopoverOpen(false);
20906
20959
  document.addEventListener("scroll", handleScroll, true);
20907
20960
  return () => document.removeEventListener("scroll", handleScroll, true);
20908
20961
  }, [isPopoverOpen]);
20962
+ React.useEffect(() => {
20963
+ return () => {
20964
+ resizeCleanupRef.current?.();
20965
+ };
20966
+ }, []);
20909
20967
  const currentAlign = node.attrs["data-align"];
20910
20968
  function handleAlign(value) {
20911
20969
  const current = node.attrs["data-align"];
@@ -20920,15 +20978,117 @@ function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode:
20920
20978
  e.currentTarget.blur();
20921
20979
  }
20922
20980
  }
20981
+ function getAspectRatio() {
20982
+ const img = imgRef.current;
20983
+ if (!img || !img.naturalWidth || !img.naturalHeight) return null;
20984
+ return img.naturalWidth / img.naturalHeight;
20985
+ }
20986
+ function handleWidthCommit() {
20987
+ if (widthInput === "") {
20988
+ updateAttributes2({ width: null, height: null });
20989
+ return;
20990
+ }
20991
+ const num = parseInt(widthInput, 10);
20992
+ if (!isNaN(num) && num >= minWidth) {
20993
+ const ratio = getAspectRatio();
20994
+ if (preserveAspectRatio && ratio) {
20995
+ const newHeight = Math.round(num / ratio);
20996
+ updateAttributes2({ width: num, height: newHeight });
20997
+ } else {
20998
+ updateAttributes2({ width: num });
20999
+ }
21000
+ } else {
21001
+ setWidthInput(node.attrs.width ? String(node.attrs.width) : "");
21002
+ }
21003
+ }
21004
+ function handleHeightCommit() {
21005
+ if (heightInput === "") {
21006
+ updateAttributes2({ height: null, width: null });
21007
+ return;
21008
+ }
21009
+ const num = parseInt(heightInput, 10);
21010
+ if (!isNaN(num) && num >= minHeight) {
21011
+ const ratio = getAspectRatio();
21012
+ if (preserveAspectRatio && ratio) {
21013
+ const newWidth = Math.round(num * ratio);
21014
+ updateAttributes2({ width: newWidth, height: num });
21015
+ } else {
21016
+ updateAttributes2({ height: num });
21017
+ }
21018
+ } else {
21019
+ setHeightInput(node.attrs.height ? String(node.attrs.height) : "");
21020
+ }
21021
+ }
21022
+ const handleResizeStart = React.useCallback(
21023
+ (e) => {
21024
+ e.preventDefault();
21025
+ e.stopPropagation();
21026
+ isResizingRef.current = true;
21027
+ const startX = e.clientX;
21028
+ const img = imgRef.current;
21029
+ if (!img) return;
21030
+ const startWidth = img.offsetWidth;
21031
+ const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : null;
21032
+ function onMouseMove(moveEvent) {
21033
+ const diff = moveEvent.clientX - startX;
21034
+ const newWidth = Math.max(minWidth, startWidth + diff);
21035
+ if (img) {
21036
+ img.style.width = `${newWidth}px`;
21037
+ if (preserveAspectRatio && ratio) {
21038
+ img.style.height = `${Math.round(newWidth / ratio)}px`;
21039
+ }
21040
+ }
21041
+ }
21042
+ function cleanup() {
21043
+ document.removeEventListener("mousemove", onMouseMove);
21044
+ document.removeEventListener("mouseup", onMouseUp);
21045
+ resizeCleanupRef.current = null;
21046
+ }
21047
+ function onMouseUp() {
21048
+ cleanup();
21049
+ if (img) {
21050
+ updateAttributes2({
21051
+ width: Math.round(img.offsetWidth),
21052
+ height: Math.round(img.offsetHeight)
21053
+ });
21054
+ }
21055
+ requestAnimationFrame(() => {
21056
+ isResizingRef.current = false;
21057
+ });
21058
+ }
21059
+ document.addEventListener("mousemove", onMouseMove);
21060
+ document.addEventListener("mouseup", onMouseUp);
21061
+ resizeCleanupRef.current = cleanup;
21062
+ },
21063
+ [updateAttributes2, preserveAspectRatio, minWidth]
21064
+ );
20923
21065
  return /* @__PURE__ */ jsxRuntime.jsx(NodeViewWrapper, { "data-drag-handle": true, "data-align": node.attrs["data-align"] ?? void 0, children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Popover.Root, { open: isPopoverOpen, onOpenChange: setIsPopoverOpen, children: [
20924
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.Popover.Anchor, { children: /* @__PURE__ */ jsxRuntime.jsx(
20925
- "img",
21066
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Popover.Anchor, { children: /* @__PURE__ */ jsxRuntime.jsxs(
21067
+ "div",
20926
21068
  {
20927
- src: node.attrs.src,
20928
- alt: node.attrs.alt ?? "",
20929
- style: { maxWidth: "100%", display: "block" },
20930
- draggable: false,
20931
- onClick: () => setIsPopoverOpen(true)
21069
+ className: "image-wrapper",
21070
+ "data-selected": selected || isPopoverOpen || void 0,
21071
+ children: [
21072
+ /* @__PURE__ */ jsxRuntime.jsx(
21073
+ "img",
21074
+ {
21075
+ ref: imgRef,
21076
+ src: node.attrs.src,
21077
+ alt: node.attrs.alt ?? "",
21078
+ style: {
21079
+ display: "block",
21080
+ maxWidth: "100%",
21081
+ width: currentWidth ? `${currentWidth}px` : void 0,
21082
+ height: currentHeight ? `${currentHeight}px` : "auto"
21083
+ },
21084
+ draggable: false,
21085
+ onClick: () => {
21086
+ if (!isResizingRef.current) setIsPopoverOpen(true);
21087
+ }
21088
+ }
21089
+ ),
21090
+ resizeEnabled && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "image-resize-handle", onMouseDown: handleResizeStart })
21091
+ ]
20932
21092
  }
20933
21093
  ) }),
20934
21094
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Popover.Content, { side: "bottom", children: [
@@ -20967,6 +21127,43 @@ function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode:
20967
21127
  }
20968
21128
  )
20969
21129
  ] }),
21130
+ resizeEnabled && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "center", gap: "4px", padding: "8px", paddingBottom: "0" }, children: [
21131
+ /* @__PURE__ */ jsxRuntime.jsx(
21132
+ designSystem.TextInput,
21133
+ {
21134
+ type: "number",
21135
+ placeholder: "W",
21136
+ value: widthInput,
21137
+ onChange: (e) => setWidthInput(e.target.value),
21138
+ onBlur: handleWidthCommit,
21139
+ onKeyDown: handleKeyDown2,
21140
+ "aria-label": formatMessage({ id: "tiptap-editor.image.width", defaultMessage: "Width (px)" }),
21141
+ style: { minWidth: "62px", flexGrow: 1 }
21142
+ }
21143
+ ),
21144
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: "1.1rem", color: "#999" }, children: "×" }),
21145
+ /* @__PURE__ */ jsxRuntime.jsx(
21146
+ designSystem.TextInput,
21147
+ {
21148
+ type: "number",
21149
+ placeholder: "H",
21150
+ value: heightInput,
21151
+ onChange: (e) => setHeightInput(e.target.value),
21152
+ onBlur: handleHeightCommit,
21153
+ onKeyDown: handleKeyDown2,
21154
+ "aria-label": formatMessage({ id: "tiptap-editor.image.height", defaultMessage: "Height (px)" }),
21155
+ style: { minWidth: "62px", flexGrow: 1 }
21156
+ }
21157
+ ),
21158
+ (currentWidth || currentHeight) && /* @__PURE__ */ jsxRuntime.jsx(
21159
+ designSystem.IconButton,
21160
+ {
21161
+ onClick: () => updateAttributes2({ width: null, height: null }),
21162
+ label: formatMessage({ id: "tiptap-editor.image.resetSize", defaultMessage: "Reset size" }),
21163
+ children: /* @__PURE__ */ jsxRuntime.jsx(icons.Cross, {})
21164
+ }
21165
+ )
21166
+ ] }),
20970
21167
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "center", gap: "8px", padding: "8px" }, children: [
20971
21168
  /* @__PURE__ */ jsxRuntime.jsx(
20972
21169
  designSystem.TextInput,
@@ -20992,12 +21189,21 @@ function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode:
20992
21189
  ] }) });
20993
21190
  }
20994
21191
  function ImageNodeViewReadOnly({ node }) {
21192
+ const rawWidth = node.attrs.width;
21193
+ const rawHeight = node.attrs.height;
21194
+ const width = typeof rawWidth === "number" ? rawWidth : null;
21195
+ const height = typeof rawHeight === "number" ? rawHeight : null;
20995
21196
  return /* @__PURE__ */ jsxRuntime.jsx(NodeViewWrapper, { "data-drag-handle": true, "data-align": node.attrs["data-align"] ?? void 0, children: /* @__PURE__ */ jsxRuntime.jsx(
20996
21197
  "img",
20997
21198
  {
20998
21199
  src: node.attrs.src,
20999
21200
  alt: node.attrs.alt ?? "",
21000
- style: { maxWidth: "100%", display: "block" },
21201
+ style: {
21202
+ maxWidth: width ? void 0 : "100%",
21203
+ display: "block",
21204
+ width: width ? `${width}px` : void 0,
21205
+ height: height ? `${height}px` : void 0
21206
+ },
21001
21207
  draggable: false
21002
21208
  }
21003
21209
  ) });
@@ -29500,7 +29706,8 @@ function buildExtensions(config) {
29500
29706
  extensions.push(index_default.configure({ multicolor: true }));
29501
29707
  }
29502
29708
  if (isFeatureEnabled(config.mediaLibrary)) {
29503
- extensions.push(StrapiImage);
29709
+ const mediaOpts = getFeatureOptions(config.mediaLibrary, {});
29710
+ extensions.push(StrapiImage.configure(mediaOpts ?? {}));
29504
29711
  } else {
29505
29712
  extensions.push(StrapiImage.configure({ enableContentCheck: true }));
29506
29713
  }
@@ -1,12 +1,12 @@
1
1
  import { jsx, jsxs, Fragment as Fragment$1 } from "react/jsx-runtime";
2
- import React, { useRef, useState, useDebugValue, useEffect, useLayoutEffect, forwardRef, createRef, memo, createElement, createContext, version, useContext, useMemo, Component } from "react";
2
+ import React, { useRef, useState, useDebugValue, useEffect, useLayoutEffect, forwardRef, createRef, memo, createElement, createContext, version, useContext, useMemo, Component, useCallback } from "react";
3
3
  import { useIntl } from "react-intl";
4
4
  import { Field, Box, Status, Typography, Flex, Button, Tooltip, Dialog, TextInput, SingleSelect, SingleSelectOption, Popover, IconButton } from "@strapi/design-system";
5
5
  import ReactDOM, { flushSync } from "react-dom";
6
6
  import styled from "styled-components";
7
7
  import { useField, useFetchClient } from "@strapi/strapi/admin";
8
- import { Quotes, Code as Code$1, NumberList, BulletList as BulletList$1, StrikeThrough, Underline as Underline$1, Italic as Italic$1, Bold as Bold$1, Link as Link$1, Trash, Image as Image$1, GridNine } from "@strapi/icons";
9
- import { g as getMediaLibraryComponent, a as getThemeCache } from "./index-sX8SY6P-.mjs";
8
+ import { Quotes, Code as Code$1, NumberList, BulletList as BulletList$1, StrikeThrough, Underline as Underline$1, Italic as Italic$1, Bold as Bold$1, Link as Link$1, Cross, Trash, Image as Image$1, GridNine } from "@strapi/icons";
9
+ import { g as getMediaLibraryComponent, a as getThemeCache } from "./index-BVPd2eP4.mjs";
10
10
  var shim = { exports: {} };
11
11
  var useSyncExternalStoreShim_production = {};
12
12
  /**
@@ -19903,19 +19903,53 @@ const TiptapInputStyles = styled.div`
19903
19903
  margin: 0.75em 0;
19904
19904
  }
19905
19905
 
19906
- /* Image alignment — margin-based, no float */
19907
- [data-align="center"] img {
19908
- margin-left: auto;
19909
- margin-right: auto;
19906
+ /* Image alignment — text-align on the node wrapper centres the inline-block image container */
19907
+ [data-align="center"] {
19908
+ text-align: center;
19910
19909
  }
19911
19910
 
19912
- [data-align="right"] img {
19913
- margin-left: auto;
19914
- margin-right: 0;
19911
+ [data-align="right"] {
19912
+ text-align: right;
19915
19913
  }
19916
19914
 
19917
19915
  /* data-align="left" and null: natural left flow, no rule needed */
19918
19916
 
19917
+ /* Inline-block wrapper for positioning the resize handle relative to the image */
19918
+ .image-wrapper {
19919
+ position: relative;
19920
+ display: inline-block;
19921
+ max-width: 100%;
19922
+ line-height: 0; /* prevent extra space below img */
19923
+ }
19924
+
19925
+ .image-wrapper img {
19926
+ display: block;
19927
+ max-width: 100%;
19928
+ height: auto;
19929
+ }
19930
+
19931
+ /* Resize handle — bottom-right corner */
19932
+ .image-resize-handle {
19933
+ position: absolute;
19934
+ right: -4px;
19935
+ bottom: -4px;
19936
+ width: 12px;
19937
+ height: 12px;
19938
+ background: #4945ff;
19939
+ border: 2px solid #fff;
19940
+ border-radius: 50%;
19941
+ cursor: nwse-resize;
19942
+ opacity: 0;
19943
+ transition: opacity 0.15s;
19944
+ z-index: 1;
19945
+ }
19946
+
19947
+ .image-wrapper:hover .image-resize-handle,
19948
+ .image-wrapper[data-selected] .image-resize-handle,
19949
+ .ProseMirror-selectednode .image-resize-handle {
19950
+ opacity: 1;
19951
+ }
19952
+
19919
19953
  // Source: https://tiptap.dev/docs/editor/extensions/nodes/table
19920
19954
 
19921
19955
  .ProseMirror {
@@ -20887,19 +20921,43 @@ function TextAlignRight(props) {
20887
20921
  }
20888
20922
  );
20889
20923
  }
20890
- function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode: deleteNode2 }) {
20924
+ function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode: deleteNode2, selected, extension }) {
20891
20925
  const { formatMessage } = useIntl();
20892
20926
  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
20893
20927
  const [altText, setAltText] = useState(node.attrs.alt ?? "");
20928
+ const imgRef = useRef(null);
20929
+ const isResizingRef = useRef(false);
20930
+ const resizeCleanupRef = useRef(null);
20931
+ const rawResize = extension.options.resize;
20932
+ const resizeEnabled = typeof rawResize === "object" && !!rawResize ? rawResize.enabled : !!rawResize;
20933
+ const resizeOpts = resizeEnabled && typeof rawResize === "object" ? rawResize : void 0;
20934
+ const preserveAspectRatio = resizeOpts?.alwaysPreserveAspectRatio ?? true;
20935
+ const minWidth = resizeOpts?.minWidth ?? 50;
20936
+ const minHeight = resizeOpts?.minHeight ?? 50;
20937
+ const currentWidth = node.attrs.width;
20938
+ const currentHeight = node.attrs.height;
20939
+ const [widthInput, setWidthInput] = useState(currentWidth ? String(currentWidth) : "");
20940
+ const [heightInput, setHeightInput] = useState(currentHeight ? String(currentHeight) : "");
20894
20941
  useEffect(() => {
20895
20942
  setAltText(node.attrs.alt ?? "");
20896
20943
  }, [node.attrs.alt]);
20944
+ useEffect(() => {
20945
+ setWidthInput(node.attrs.width ? String(node.attrs.width) : "");
20946
+ }, [node.attrs.width]);
20947
+ useEffect(() => {
20948
+ setHeightInput(node.attrs.height ? String(node.attrs.height) : "");
20949
+ }, [node.attrs.height]);
20897
20950
  useEffect(() => {
20898
20951
  if (!isPopoverOpen) return;
20899
20952
  const handleScroll = () => setIsPopoverOpen(false);
20900
20953
  document.addEventListener("scroll", handleScroll, true);
20901
20954
  return () => document.removeEventListener("scroll", handleScroll, true);
20902
20955
  }, [isPopoverOpen]);
20956
+ useEffect(() => {
20957
+ return () => {
20958
+ resizeCleanupRef.current?.();
20959
+ };
20960
+ }, []);
20903
20961
  const currentAlign = node.attrs["data-align"];
20904
20962
  function handleAlign(value) {
20905
20963
  const current = node.attrs["data-align"];
@@ -20914,15 +20972,117 @@ function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode:
20914
20972
  e.currentTarget.blur();
20915
20973
  }
20916
20974
  }
20975
+ function getAspectRatio() {
20976
+ const img = imgRef.current;
20977
+ if (!img || !img.naturalWidth || !img.naturalHeight) return null;
20978
+ return img.naturalWidth / img.naturalHeight;
20979
+ }
20980
+ function handleWidthCommit() {
20981
+ if (widthInput === "") {
20982
+ updateAttributes2({ width: null, height: null });
20983
+ return;
20984
+ }
20985
+ const num = parseInt(widthInput, 10);
20986
+ if (!isNaN(num) && num >= minWidth) {
20987
+ const ratio = getAspectRatio();
20988
+ if (preserveAspectRatio && ratio) {
20989
+ const newHeight = Math.round(num / ratio);
20990
+ updateAttributes2({ width: num, height: newHeight });
20991
+ } else {
20992
+ updateAttributes2({ width: num });
20993
+ }
20994
+ } else {
20995
+ setWidthInput(node.attrs.width ? String(node.attrs.width) : "");
20996
+ }
20997
+ }
20998
+ function handleHeightCommit() {
20999
+ if (heightInput === "") {
21000
+ updateAttributes2({ height: null, width: null });
21001
+ return;
21002
+ }
21003
+ const num = parseInt(heightInput, 10);
21004
+ if (!isNaN(num) && num >= minHeight) {
21005
+ const ratio = getAspectRatio();
21006
+ if (preserveAspectRatio && ratio) {
21007
+ const newWidth = Math.round(num * ratio);
21008
+ updateAttributes2({ width: newWidth, height: num });
21009
+ } else {
21010
+ updateAttributes2({ height: num });
21011
+ }
21012
+ } else {
21013
+ setHeightInput(node.attrs.height ? String(node.attrs.height) : "");
21014
+ }
21015
+ }
21016
+ const handleResizeStart = useCallback(
21017
+ (e) => {
21018
+ e.preventDefault();
21019
+ e.stopPropagation();
21020
+ isResizingRef.current = true;
21021
+ const startX = e.clientX;
21022
+ const img = imgRef.current;
21023
+ if (!img) return;
21024
+ const startWidth = img.offsetWidth;
21025
+ const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : null;
21026
+ function onMouseMove(moveEvent) {
21027
+ const diff = moveEvent.clientX - startX;
21028
+ const newWidth = Math.max(minWidth, startWidth + diff);
21029
+ if (img) {
21030
+ img.style.width = `${newWidth}px`;
21031
+ if (preserveAspectRatio && ratio) {
21032
+ img.style.height = `${Math.round(newWidth / ratio)}px`;
21033
+ }
21034
+ }
21035
+ }
21036
+ function cleanup() {
21037
+ document.removeEventListener("mousemove", onMouseMove);
21038
+ document.removeEventListener("mouseup", onMouseUp);
21039
+ resizeCleanupRef.current = null;
21040
+ }
21041
+ function onMouseUp() {
21042
+ cleanup();
21043
+ if (img) {
21044
+ updateAttributes2({
21045
+ width: Math.round(img.offsetWidth),
21046
+ height: Math.round(img.offsetHeight)
21047
+ });
21048
+ }
21049
+ requestAnimationFrame(() => {
21050
+ isResizingRef.current = false;
21051
+ });
21052
+ }
21053
+ document.addEventListener("mousemove", onMouseMove);
21054
+ document.addEventListener("mouseup", onMouseUp);
21055
+ resizeCleanupRef.current = cleanup;
21056
+ },
21057
+ [updateAttributes2, preserveAspectRatio, minWidth]
21058
+ );
20917
21059
  return /* @__PURE__ */ jsx(NodeViewWrapper, { "data-drag-handle": true, "data-align": node.attrs["data-align"] ?? void 0, children: /* @__PURE__ */ jsxs(Popover.Root, { open: isPopoverOpen, onOpenChange: setIsPopoverOpen, children: [
20918
- /* @__PURE__ */ jsx(Popover.Anchor, { children: /* @__PURE__ */ jsx(
20919
- "img",
21060
+ /* @__PURE__ */ jsx(Popover.Anchor, { children: /* @__PURE__ */ jsxs(
21061
+ "div",
20920
21062
  {
20921
- src: node.attrs.src,
20922
- alt: node.attrs.alt ?? "",
20923
- style: { maxWidth: "100%", display: "block" },
20924
- draggable: false,
20925
- onClick: () => setIsPopoverOpen(true)
21063
+ className: "image-wrapper",
21064
+ "data-selected": selected || isPopoverOpen || void 0,
21065
+ children: [
21066
+ /* @__PURE__ */ jsx(
21067
+ "img",
21068
+ {
21069
+ ref: imgRef,
21070
+ src: node.attrs.src,
21071
+ alt: node.attrs.alt ?? "",
21072
+ style: {
21073
+ display: "block",
21074
+ maxWidth: "100%",
21075
+ width: currentWidth ? `${currentWidth}px` : void 0,
21076
+ height: currentHeight ? `${currentHeight}px` : "auto"
21077
+ },
21078
+ draggable: false,
21079
+ onClick: () => {
21080
+ if (!isResizingRef.current) setIsPopoverOpen(true);
21081
+ }
21082
+ }
21083
+ ),
21084
+ resizeEnabled && /* @__PURE__ */ jsx("div", { className: "image-resize-handle", onMouseDown: handleResizeStart })
21085
+ ]
20926
21086
  }
20927
21087
  ) }),
20928
21088
  /* @__PURE__ */ jsxs(Popover.Content, { side: "bottom", children: [
@@ -20961,6 +21121,43 @@ function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode:
20961
21121
  }
20962
21122
  )
20963
21123
  ] }),
21124
+ resizeEnabled && /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", gap: "4px", padding: "8px", paddingBottom: "0" }, children: [
21125
+ /* @__PURE__ */ jsx(
21126
+ TextInput,
21127
+ {
21128
+ type: "number",
21129
+ placeholder: "W",
21130
+ value: widthInput,
21131
+ onChange: (e) => setWidthInput(e.target.value),
21132
+ onBlur: handleWidthCommit,
21133
+ onKeyDown: handleKeyDown2,
21134
+ "aria-label": formatMessage({ id: "tiptap-editor.image.width", defaultMessage: "Width (px)" }),
21135
+ style: { minWidth: "62px", flexGrow: 1 }
21136
+ }
21137
+ ),
21138
+ /* @__PURE__ */ jsx("span", { style: { fontSize: "1.1rem", color: "#999" }, children: "×" }),
21139
+ /* @__PURE__ */ jsx(
21140
+ TextInput,
21141
+ {
21142
+ type: "number",
21143
+ placeholder: "H",
21144
+ value: heightInput,
21145
+ onChange: (e) => setHeightInput(e.target.value),
21146
+ onBlur: handleHeightCommit,
21147
+ onKeyDown: handleKeyDown2,
21148
+ "aria-label": formatMessage({ id: "tiptap-editor.image.height", defaultMessage: "Height (px)" }),
21149
+ style: { minWidth: "62px", flexGrow: 1 }
21150
+ }
21151
+ ),
21152
+ (currentWidth || currentHeight) && /* @__PURE__ */ jsx(
21153
+ IconButton,
21154
+ {
21155
+ onClick: () => updateAttributes2({ width: null, height: null }),
21156
+ label: formatMessage({ id: "tiptap-editor.image.resetSize", defaultMessage: "Reset size" }),
21157
+ children: /* @__PURE__ */ jsx(Cross, {})
21158
+ }
21159
+ )
21160
+ ] }),
20964
21161
  /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", gap: "8px", padding: "8px" }, children: [
20965
21162
  /* @__PURE__ */ jsx(
20966
21163
  TextInput,
@@ -20986,12 +21183,21 @@ function ImageNodeView({ node, updateAttributes: updateAttributes2, deleteNode:
20986
21183
  ] }) });
20987
21184
  }
20988
21185
  function ImageNodeViewReadOnly({ node }) {
21186
+ const rawWidth = node.attrs.width;
21187
+ const rawHeight = node.attrs.height;
21188
+ const width = typeof rawWidth === "number" ? rawWidth : null;
21189
+ const height = typeof rawHeight === "number" ? rawHeight : null;
20989
21190
  return /* @__PURE__ */ jsx(NodeViewWrapper, { "data-drag-handle": true, "data-align": node.attrs["data-align"] ?? void 0, children: /* @__PURE__ */ jsx(
20990
21191
  "img",
20991
21192
  {
20992
21193
  src: node.attrs.src,
20993
21194
  alt: node.attrs.alt ?? "",
20994
- style: { maxWidth: "100%", display: "block" },
21195
+ style: {
21196
+ maxWidth: width ? void 0 : "100%",
21197
+ display: "block",
21198
+ width: width ? `${width}px` : void 0,
21199
+ height: height ? `${height}px` : void 0
21200
+ },
20995
21201
  draggable: false
20996
21202
  }
20997
21203
  ) });
@@ -29494,7 +29700,8 @@ function buildExtensions(config) {
29494
29700
  extensions.push(index_default.configure({ multicolor: true }));
29495
29701
  }
29496
29702
  if (isFeatureEnabled(config.mediaLibrary)) {
29497
- extensions.push(StrapiImage);
29703
+ const mediaOpts = getFeatureOptions(config.mediaLibrary, {});
29704
+ extensions.push(StrapiImage.configure(mediaOpts ?? {}));
29498
29705
  } else {
29499
29706
  extensions.push(StrapiImage.configure({ enableContentCheck: true }));
29500
29707
  }
@@ -146,7 +146,7 @@ const richTextField = {
146
146
  },
147
147
  icon: Paragraph,
148
148
  components: {
149
- Input: async () => import("./RichTextInput-B8CLPOyo.mjs").then((m) => ({ default: m.default }))
149
+ Input: async () => import("./RichTextInput-B4D0Djcf.mjs").then((m) => ({ default: m.default }))
150
150
  },
151
151
  options: {
152
152
  advanced: [
@@ -147,7 +147,7 @@ const richTextField = {
147
147
  },
148
148
  icon: icons.Paragraph,
149
149
  components: {
150
- Input: async () => Promise.resolve().then(() => require("./RichTextInput-EtL-yFqV.js")).then((m) => ({ default: m.default }))
150
+ Input: async () => Promise.resolve().then(() => require("./RichTextInput-1uZSm3Nc.js")).then((m) => ({ default: m.default }))
151
151
  },
152
152
  options: {
153
153
  advanced: [
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-yXpX_VsO.js");
2
+ const index = require("../_chunks/index-CTcKIvYv.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-sX8SY6P-.mjs";
1
+ import { i } from "../_chunks/index-BVPd2eP4.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -1,3 +1,3 @@
1
1
  import type { NodeViewProps } from '@tiptap/react';
2
- export declare function ImageNodeView({ node, updateAttributes, deleteNode }: NodeViewProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function ImageNodeView({ node, updateAttributes, deleteNode, selected, extension }: NodeViewProps): import("react/jsx-runtime").JSX.Element;
3
3
  export default ImageNodeView;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.1",
2
+ "version": "1.2.1",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "plugin",