@griddo/ax 12.13.0-rc.1 → 12.13.1-beta.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
@@ -9,27 +9,27 @@
9
9
 
10
10
  ### Desarrollo
11
11
 
12
- - `pnpm start` - Alias para `start:dev`
13
- - `pnpm start:local` - Inicia el servidor de desarrollo en entorno local
14
- - `pnpm start:dev` - Inicia el servidor de desarrollo en entorno de desarrollo
15
- - `pnpm start:staging` - Inicia el servidor de desarrollo en entorno de staging
12
+ - `yarn start` - Alias para `start:dev`
13
+ - `yarn start:local` - Inicia el servidor de desarrollo en entorno local
14
+ - `yarn start:dev` - Inicia el servidor de desarrollo en entorno de desarrollo
15
+ - `yarn start:staging` - Inicia el servidor de desarrollo en entorno de staging
16
16
 
17
17
  ### Build
18
18
 
19
- - `pnpm build` - Construye la aplicación para producción
20
- - `pnpm build:dev` - Construye la aplicación para desarrollo
19
+ - `yarn build` - Construye la aplicación para producción
20
+ - `yarn build:dev` - Construye la aplicación para desarrollo
21
21
 
22
22
  ### Testing
23
23
 
24
- - `pnpm test` - Ejecuta los tests con Jest
25
- - `pnpm test:watch` - Ejecuta los tests en modo watch
26
- - `pnpm test:ci` - Ejecuta los tests en modo CI (sin paralelización)
27
- - `pnpm test:coverage` - Ejecuta los tests y genera reporte de cobertura
24
+ - `yarn test` - Ejecuta los tests con Jest
25
+ - `yarn test:watch` - Ejecuta los tests en modo watch
26
+ - `yarn test:ci` - Ejecuta los tests en modo CI (sin paralelización)
27
+ - `yarn test:coverage` - Ejecuta los tests y genera reporte de cobertura
28
28
 
29
29
  ### Linting y Formato
30
30
 
31
- - `pnpm lint` - Verifica el código con Biome (sin escribir)
32
- - `pnpm format:write` - Formatea el código con Biome (escribiendo)
31
+ - `yarn lint` - Verifica el código con Biome (sin escribir)
32
+ - `yarn format:write` - Formatea el código con Biome (escribiendo)
33
33
 
34
34
  ¿Quieres comprobar solo una regla o categoría específica?
35
35
  ```bash
@@ -83,7 +83,7 @@ const hasJsxRuntime = (() => {
83
83
 
84
84
  // This is the production and development configuration.
85
85
  // It is focused on developer experience, fast rebuilds, and a minimal bundle.
86
- module.exports = (webpackEnv) => {
86
+ module.exports = function (webpackEnv) {
87
87
  const isEnvDevelopment = webpackEnv === "development";
88
88
  const isEnvProduction = webpackEnv === "production";
89
89
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@griddo/ax",
3
3
  "description": "Griddo Author Experience",
4
- "version": "12.13.0-rc.1",
4
+ "version": "12.13.1-beta.1",
5
5
  "homepage": "https://griddo.io",
6
6
  "repository": {
7
7
  "type": "git",
@@ -127,7 +127,7 @@
127
127
  },
128
128
  "devDependencies": {
129
129
  "@biomejs/biome": "2.5.15",
130
- "@griddo/core": "12.13.0-rc.1",
130
+ "@griddo/core": "12.13.1-beta.1",
131
131
  "@types/redux-mock-store": "1.5.0",
132
132
  "@vitest/coverage-v8": "4.1.11",
133
133
  "axios-mock-adapter": "1.22.0",
@@ -138,7 +138,7 @@
138
138
  "vitest-styled-components": "1.0.0"
139
139
  },
140
140
  "peerDependencies": {
141
- "@griddo/core": "12.13.0-rc.1"
141
+ "@griddo/core": "12.13.1-beta.1"
142
142
  },
143
143
  "babel": {
144
144
  "presets": [
@@ -171,5 +171,5 @@
171
171
  "publishConfig": {
172
172
  "access": "public"
173
173
  },
174
- "gitHead": "95c5d6a95667cad124b62e06cdabbb56baff05a2"
174
+ "gitHead": "2570d2896bbdf191bdc40861f422ca1d053f6e5c"
175
175
  }
@@ -0,0 +1,232 @@
1
+ import Wysiwyg from "@ax/components/Fields/Wysiwyg";
2
+ import { parseTheme } from "@ax/helpers";
3
+ import globalTheme from "@ax/themes/theme.json";
4
+
5
+ import { config } from "components";
6
+ import FroalaEditor from "froala-editor";
7
+ import configureStore from "redux-mock-store";
8
+ import { ThemeProvider } from "styled-components";
9
+
10
+ import { cleanup, render, waitFor } from "../../../../../config/tests/test-utils";
11
+
12
+ // The real Froala, not the mock the rest of the Wysiwyg tests use: the bug lives
13
+ // in its image plugin, so only the plugin itself can show it is gone.
14
+
15
+ const schemasConfig = config.schemas.config;
16
+ const store = configureStore()({ app: { token: "token" } });
17
+
18
+ const mountEditor = async (value: string) => {
19
+ const { container } = render(
20
+ <ThemeProvider theme={parseTheme(globalTheme)}>
21
+ <Wysiwyg value={value} onChange={vi.fn()} site={null} />
22
+ </ThemeProvider>,
23
+ { store },
24
+ );
25
+ let editor: any;
26
+ await waitFor(() => {
27
+ const el = container.querySelector(".fr-element");
28
+ editor = (FroalaEditor as any).INSTANCES.find((instance: any) => instance.el === el);
29
+ expect(editor?.image).toBeTruthy();
30
+ });
31
+ return editor;
32
+ };
33
+
34
+ const toggleCaption = (editor: any, img: HTMLElement = editor.el.querySelector("img")) => {
35
+ editor.image.edit(editor.$(img));
36
+ editor.commands.exec("imageCaption");
37
+ };
38
+
39
+ // jsdom has no layout and no DOMRect
40
+ const rect = (width: number) =>
41
+ ({ x: 0, y: 0, top: 0, left: 0, right: width, bottom: 200, width, height: 200 }) as DOMRect;
42
+
43
+ const caption = (editor: any): HTMLElement | null => editor.el.querySelector(".fr-img-caption");
44
+
45
+ describe("Wysiwyg image caption", () => {
46
+ beforeAll(() => {
47
+ // Froala's licence check splits document.domain, which jsdom leaves undefined
48
+ Object.defineProperty(document, "domain", { value: "localhost", configurable: true });
49
+ });
50
+
51
+ afterEach(() => {
52
+ cleanup();
53
+ delete schemasConfig.richTextConfig;
54
+ });
55
+
56
+ describe("with image resizing on", () => {
57
+ beforeEach(() => {
58
+ schemasConfig.richTextConfig = { imageResize: true };
59
+ });
60
+
61
+ it.each([
62
+ ["a new image in a paragraph", '<p><img src="http://x/a.png" alt="" class="fr-dib"></p>'],
63
+ ["a linked image", '<p><a href="http://x"><img src="http://x/a.png" alt="" class="fr-dib"></a></p>'],
64
+ ["an image outside a paragraph", '<ul><li><img src="http://x/a.png" alt="" class="fr-dib"></li></ul>'],
65
+ ])("should open an editable caption under %s", async (_, html) => {
66
+ const editor = await mountEditor(html);
67
+
68
+ toggleCaption(editor);
69
+
70
+ const inner = caption(editor)?.querySelector(".fr-inner");
71
+ expect(inner?.textContent).toBe("Image Caption");
72
+ expect(inner?.getAttribute("contenteditable")).toBe("true");
73
+ });
74
+
75
+ it("should give the caption the width the image is showing at", async () => {
76
+ const editor = await mountEditor('<p><img src="http://x/a.png" alt="" class="fr-dib"></p>');
77
+ vi.spyOn(editor.el.querySelector("img"), "getBoundingClientRect").mockReturnValue(rect(300));
78
+ vi.spyOn(editor.el.querySelector("p"), "getBoundingClientRect").mockReturnValue(rect(600));
79
+
80
+ toggleCaption(editor);
81
+
82
+ expect(caption(editor)?.style.width).toBe("50%");
83
+ });
84
+
85
+ it("should fall back to the full width when the image cannot be measured", async () => {
86
+ const editor = await mountEditor('<p><img src="http://x/a.png" alt="" class="fr-dib"></p>');
87
+
88
+ toggleCaption(editor);
89
+
90
+ expect(caption(editor)?.style.width).toBe("100%");
91
+ });
92
+
93
+ it("should move a resized image's width to its caption and back", async () => {
94
+ const editor = await mountEditor('<p><img src="http://x/a.png" alt="" class="fr-dib" style="width: 40%;"></p>');
95
+ const img = editor.el.querySelector("img");
96
+
97
+ toggleCaption(editor);
98
+
99
+ expect(caption(editor)?.style.width).toBe("40%");
100
+ expect(img.style.width).toBe("");
101
+
102
+ toggleCaption(editor);
103
+
104
+ expect(caption(editor)).toBeNull();
105
+ expect(img.style.width).toBe("40%");
106
+ });
107
+
108
+ it("should not leave the width it hands Froala in the content", async () => {
109
+ const editor = await mountEditor('<p><img src="http://x/a.png" alt="" class="fr-dib"></p>');
110
+
111
+ toggleCaption(editor);
112
+
113
+ expect(editor.el.querySelector("[width]")).toBeNull();
114
+ expect(editor.el.querySelector("img").hasAttribute("style")).toBe(false);
115
+ });
116
+
117
+ it("should turn a pixel width from before percentages into the caption's share", async () => {
118
+ const editor = await mountEditor(
119
+ '<ul><li><img src="http://x/a.png" alt="" class="fr-dib" style="width: 300px;"></li></ul>',
120
+ );
121
+ const img = editor.el.querySelector("img");
122
+ vi.spyOn(img, "getBoundingClientRect").mockReturnValue(rect(300));
123
+ vi.spyOn(editor.el.querySelector("li"), "getBoundingClientRect").mockReturnValue(rect(600));
124
+
125
+ toggleCaption(editor);
126
+
127
+ expect(caption(editor)?.style.width).toBe("50%");
128
+ expect(img.style.width).toBe("");
129
+ });
130
+
131
+ it("should keep a resized image's size when its caption is put back", async () => {
132
+ const editor = await mountEditor('<p><img src="http://x/a.png" alt="" class="fr-dib" style="width: 40%;"></p>');
133
+ const img = editor.el.querySelector("img");
134
+
135
+ toggleCaption(editor);
136
+ toggleCaption(editor);
137
+ toggleCaption(editor);
138
+
139
+ expect(caption(editor)?.style.width).toBe("40%");
140
+ expect(img.style.width).toBe("");
141
+ expect(editor.el.querySelectorAll(".fr-img-space-wrap")).toHaveLength(1);
142
+ });
143
+
144
+ it("should leave the other captions alone when an image is captioned again", async () => {
145
+ const editor = await mountEditor(
146
+ '<p><img id="first" src="http://x/a.png" alt="" class="fr-dib"></p><p><img id="second" src="http://x/b.png" alt="" class="fr-dib"></p>',
147
+ );
148
+ const first = editor.el.querySelector("#first");
149
+ const second = editor.el.querySelector("#second");
150
+
151
+ toggleCaption(editor, first);
152
+ toggleCaption(editor, second);
153
+ toggleCaption(editor, second);
154
+ toggleCaption(editor, second);
155
+
156
+ expect(first.closest(".fr-img-space-wrap")).not.toBeNull();
157
+ expect(first.closest(".fr-img-caption")).not.toBeNull();
158
+ expect(second.closest(".fr-img-caption")).not.toBeNull();
159
+ expect(editor.el.querySelectorAll(".fr-img-space-wrap")).toHaveLength(2);
160
+ });
161
+
162
+ describe("when resizing a captioned image", () => {
163
+ // jsdom has no layout: the column is 600px, and the caption, with the
164
+ // paragraph and image inside it, takes the share its style asks for
165
+ const COLUMN = 600;
166
+
167
+ beforeEach(() => {
168
+ vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
169
+ const box = this.closest(".fr-img-caption") as HTMLElement | null;
170
+ return box ? (Number.parseFloat(box.style.width) / 100) * COLUMN : COLUMN;
171
+ });
172
+ });
173
+
174
+ const captioned = async () => {
175
+ const editor = await mountEditor('<p><img src="http://x/a.png" alt="" class="fr-dib" style="width: 50%;"></p>');
176
+ const img = editor.el.querySelector("img");
177
+ toggleCaption(editor);
178
+ editor.image.edit(editor.$(img));
179
+ return { editor, img };
180
+ };
181
+
182
+ // Froala reads pageX, which jsdom does not derive from clientX
183
+ const mouse = (type: string, pageX = 0) =>
184
+ Object.defineProperty(new MouseEvent(type, { bubbles: true }), "pageX", { value: pageX });
185
+
186
+ const dragHandle = (moves: number[]) => {
187
+ const handle = document.querySelector(".fr-image-resizer .fr-hse") as HTMLElement;
188
+ handle.dispatchEvent(mouse("mousedown", 100));
189
+ return moves.map((x) => {
190
+ document.dispatchEvent(mouse("mousemove", x));
191
+ return (document.querySelector(".fr-img-caption") as HTMLElement).style.width;
192
+ });
193
+ };
194
+
195
+ it("should follow the pointer without bouncing", async () => {
196
+ await captioned();
197
+
198
+ expect(dragHandle([160, 160, 160, 130])).toEqual(["60%", "60%", "60%", "55%"]);
199
+ });
200
+
201
+ it("should put the caption back in its paragraph when the drag ends", async () => {
202
+ const { img } = await captioned();
203
+
204
+ dragHandle([160]);
205
+ document.body.dispatchEvent(mouse("mouseup"));
206
+
207
+ const paragraph = img.closest(".fr-img-wrap > p");
208
+ expect(paragraph).not.toBeNull();
209
+ expect(paragraph.querySelector(".fr-inner")?.textContent).toBe("Image Caption");
210
+ });
211
+ });
212
+
213
+ it("should remove the caption it added", async () => {
214
+ const editor = await mountEditor('<p><img src="http://x/a.png" alt="" class="fr-dib"></p>');
215
+
216
+ toggleCaption(editor);
217
+ toggleCaption(editor);
218
+
219
+ expect(caption(editor)).toBeNull();
220
+ expect(editor.el.querySelector(".fr-inner")).toBeNull();
221
+ expect(editor.el.querySelector("img")).not.toBeNull();
222
+ });
223
+ });
224
+
225
+ it("should leave Froala's pixel caption alone when resizing is off", async () => {
226
+ const editor = await mountEditor('<p><img src="http://x/a.png" alt="" class="fr-dib"></p>');
227
+
228
+ toggleCaption(editor);
229
+
230
+ expect(caption(editor)?.style.width).toMatch(/px$/);
231
+ });
232
+ });
@@ -0,0 +1,75 @@
1
+ import Wysiwyg from "@ax/components/Fields/Wysiwyg";
2
+ import { parseTheme } from "@ax/helpers";
3
+ import globalTheme from "@ax/themes/theme.json";
4
+
5
+ import FroalaEditor from "froala-editor";
6
+ import configureStore from "redux-mock-store";
7
+ import { ThemeProvider } from "styled-components";
8
+
9
+ import { cleanup, render, waitFor } from "../../../../../config/tests/test-utils";
10
+
11
+ // The real Froala, not the mock the rest of the Wysiwyg tests use: the bug lives
12
+ // in its sticky toolbar, so only Froala itself can show it is gone.
13
+
14
+ const store = configureStore()({ app: { token: "token" } });
15
+
16
+ const mountEditor = async () => {
17
+ const { container } = render(
18
+ <ThemeProvider theme={parseTheme(globalTheme)}>
19
+ <Wysiwyg value="<p>Lorem ipsum</p>" onChange={vi.fn()} site={null} />
20
+ </ThemeProvider>,
21
+ { store },
22
+ );
23
+ let editor: any;
24
+ await waitFor(() => {
25
+ const el = container.querySelector(".fr-element");
26
+ editor = (FroalaEditor as any).INSTANCES.find((instance: any) => instance.el === el);
27
+ expect(editor?.$tb).toBeTruthy();
28
+ });
29
+ return editor;
30
+ };
31
+
32
+ // jsdom has no layout: Froala measures with offsetWidth/offsetHeight and places
33
+ // the box with getBoundingClientRect
34
+ const layOut = (box: HTMLElement, top: number) => {
35
+ vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(300);
36
+ vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (this: HTMLElement) {
37
+ return this === box ? 400 : 50;
38
+ });
39
+ vi.spyOn(box, "getBoundingClientRect").mockReturnValue({
40
+ x: 0,
41
+ y: top,
42
+ top,
43
+ left: 0,
44
+ right: 300,
45
+ bottom: top + 400,
46
+ width: 300,
47
+ height: 400,
48
+ } as DOMRect);
49
+ };
50
+
51
+ describe("Wysiwyg toolbar", () => {
52
+ beforeAll(() => {
53
+ // Froala's licence check splits document.domain, which jsdom leaves undefined
54
+ Object.defineProperty(document, "domain", { value: "localhost", configurable: true });
55
+ });
56
+
57
+ afterEach(() => {
58
+ cleanup();
59
+ vi.restoreAllMocks();
60
+ });
61
+
62
+ // AX scrolls an inner container, not the window, and Froala only re-places its
63
+ // sticky toolbar on window scroll, focus and resize. Focusing a half scrolled-out
64
+ // field pinned the toolbar under the app header, and scrolling back left it
65
+ // there, with an empty gap where it belonged, until the next focus.
66
+ it("should keep the toolbar in place when the field gets focus half scrolled out", async () => {
67
+ const editor = await mountEditor();
68
+ layOut(editor.$box.get(0), -100);
69
+
70
+ editor.events.trigger("focus");
71
+
72
+ expect(editor.$tb.get(0).classList.contains("fr-sticky-on")).toBe(false);
73
+ expect(editor.$box.get(0).querySelector(".fr-sticky-dummy")).toBeNull();
74
+ });
75
+ });
@@ -120,6 +120,11 @@ const getWysiwygConfig = () => ({
120
120
  pasteDeniedAttrs: ["class", "id", "style", "dir"],
121
121
  listAdvancedTypes: false,
122
122
  attribution: false,
123
+ // Froala only re-places its sticky toolbar on window scroll, but AX scrolls an
124
+ // inner container: focusing a half scrolled-out field pinned the toolbar under
125
+ // the app header and left it there. The content scrolls inside a capped box
126
+ // anyway, so the toolbar never ends up far from the text.
127
+ toolbarSticky: false,
123
128
  paragraphFormat: {
124
129
  N: "Normal",
125
130
  H1: "Heading 1",
@@ -380,8 +385,8 @@ FroalaEditor.RegisterCommand("imageReplaceGriddo", {
380
385
  });
381
386
 
382
387
  export {
383
- getButtonsFull as buttonsFull,
384
388
  getButtons as buttons,
389
+ getButtonsFull as buttonsFull,
385
390
  getInlineToolbar as inlineToolbar,
386
391
  getWysiwygConfig as wysiwygConfig,
387
392
  };
@@ -140,12 +140,25 @@ const createPopupFunctions = (editor: any, editorEl: HTMLElement) => {
140
140
  return { showPopup, showErrorPopup };
141
141
  };
142
142
 
143
+ // The width an image is showing at, as the share of its block that Froala's
144
+ // percentage resizing stores. Unmeasurable (hidden, or no layout) falls back to
145
+ // the full width, as Froala does.
146
+ const getImagePercentWidth = (editor: any, $img: any) => {
147
+ const img: HTMLElement = $img.get(0);
148
+ if (img.style.width.endsWith("%")) return img.style.width;
149
+
150
+ const block: HTMLElement = $img.parentsUntil(editor.$el, editor.html.blockTagsQuery()).get(0) || editor.el;
151
+ const percent = Math.round((img.getBoundingClientRect().width / block.getBoundingClientRect().width) * 100);
152
+ return percent > 0 && percent < 100 ? `${percent}%` : "100%";
153
+ };
154
+
143
155
  export {
144
- getRichTextConfig,
145
- parseClassNames,
156
+ createPopupFunctions,
157
+ getImagePercentWidth,
146
158
  getLanguageMenuHtml,
147
- setLinkAttributes,
148
159
  getPopupPosition,
149
- createPopupFunctions,
160
+ getRichTextConfig,
150
161
  type IRichTextConfig,
162
+ parseClassNames,
163
+ setLinkAttributes,
151
164
  };
@@ -11,7 +11,7 @@ import FroalaEditor from "froala-editor";
11
11
 
12
12
  import { LinkPopover } from "./atoms";
13
13
  import { buttons, buttonsFull, inlineToolbar, wysiwygConfig } from "./config";
14
- import { createPopupFunctions, getPopupPosition, setLinkAttributes } from "./helpers";
14
+ import { createPopupFunctions, getImagePercentWidth, getPopupPosition, setLinkAttributes } from "./helpers";
15
15
  import "./vendors";
16
16
 
17
17
  import { useModals } from "@ax/hooks";
@@ -51,6 +51,8 @@ const Wysiwyg = (props: IWysiwygProps) => {
51
51
  isImageLink?: boolean;
52
52
  } | null>(null);
53
53
  const replaceImgRef = useRef<any>(null);
54
+ const captionWidthRef = useRef<any>(null);
55
+ const liftedCaptionRef = useRef<{ paragraph: HTMLElement; nodes: ChildNode[] } | null>(null);
54
56
  const lastValueRef = useRef(value);
55
57
 
56
58
  useEffect(() => {
@@ -209,6 +211,28 @@ const Wysiwyg = (props: IWysiwygProps) => {
209
211
  const editorEl = this.$el?.get?.(0) || this.el;
210
212
  if (!editorEl) return;
211
213
 
214
+ // Froala resizes a percentage image against the nearest block around
215
+ // it, and with a caption on that is the <p> the caption keeps inside:
216
+ // every step of the drag measures against the width the step before
217
+ // just set, and the image bounces. Lift the caption's content out of
218
+ // that paragraph while dragging, so it measures against the column,
219
+ // and put it back in "image.resizeEnd". Capturing runs it before
220
+ // Froala's own mousedown on the handle.
221
+ if (this.opts.imageResizeWithPercent) {
222
+ this.$box?.get?.(0)?.addEventListener(
223
+ "mousedown",
224
+ (e: MouseEvent) => {
225
+ if (!(e.target as Element).closest?.(".fr-handler")) return;
226
+ const paragraph = this.image.get()?.get(0)?.closest(".fr-img-wrap > p");
227
+ if (!paragraph) return;
228
+ const nodes = [...paragraph.childNodes];
229
+ paragraph.replaceWith(...nodes);
230
+ liftedCaptionRef.current = { paragraph, nodes };
231
+ },
232
+ true,
233
+ );
234
+ }
235
+
212
236
  editorEl.addEventListener("griddo:openLinkPopover", (e: Event) => {
213
237
  const detail = (e as CustomEvent).detail;
214
238
  setLinkPopoverOpen(true);
@@ -292,15 +316,59 @@ const Wysiwyg = (props: IWysiwygProps) => {
292
316
  // Froala's own 2147483642, and with the option gone nothing ever clears
293
317
  // it: the toolbar then paints over every modal the field opens. Seed the
294
318
  // key the plugin reads, and drop what it left inline on the way out.
319
+ //
320
+ // With imageResizeWithPercent, Froala sizes a new caption from the width
321
+ // of the image, or of the <p>/<a> around it, and reads that width without
322
+ // checking there is one: on an image inside a paragraph, or never resized,
323
+ // it throws and no caption appears. Hand it the image's own width, and
324
+ // once the caption is in, give that width to the caption, as Froala's
325
+ // resizing expects, and take it off the image so it is not applied twice.
326
+ // Removing the caption gives it back to the image.
327
+ //
328
+ // Removing a caption also leaves the image in Froala's wrapper div, out
329
+ // of its paragraph. Captioning it again there nests a second wrapper that
330
+ // Froala then fails to unwrap, so put the image back in a paragraph first.
295
331
  "commands.before": function (this: any, cmd: string) {
296
332
  if (cmd === "fullscreen" && !this.fullscreen.isActive()) {
297
333
  this.opts.z_index = this.opts.zIndex;
298
334
  }
335
+ if (cmd === "imageCaption" && this.opts.imageResizeWithPercent && !this.image.hasCaption()) {
336
+ const $img = this.image.get();
337
+ if (!$img) return;
338
+ const img = $img.get(0);
339
+ const leftoverWrap = img.closest(".fr-img-space-wrap");
340
+ if (leftoverWrap) {
341
+ const p = document.createElement("p");
342
+ p.append(...leftoverWrap.childNodes);
343
+ leftoverWrap.replaceWith(p);
344
+ }
345
+ const width = getImagePercentWidth(this, $img);
346
+ const $sized = $img.parent().is("a") || $img.parent().is("p") ? $img.parent() : $img;
347
+ const seeded = !$sized.attr("style") && !$sized.attr("width");
348
+ if (seeded) $sized.attr("width", width);
349
+ captionWidthRef.current = { img, width, $seeded: seeded ? $sized : null };
350
+ }
299
351
  },
300
352
  "commands.after": function (this: any, cmd: string) {
301
353
  if (cmd === "fullscreen" && !this.fullscreen.isActive()) {
302
354
  this.$tb.css("z-index", "");
303
355
  }
356
+ if (cmd === "imageCaption" && captionWidthRef.current) {
357
+ const { img, width, $seeded } = captionWidthRef.current;
358
+ $seeded?.removeAttr("width");
359
+ const caption = img.closest(".fr-img-caption");
360
+ if (caption) caption.style.width = width;
361
+ img.style.removeProperty("width");
362
+ if (!img.getAttribute("style")) img.removeAttribute("style");
363
+ captionWidthRef.current = null;
364
+ }
365
+ },
366
+ "image.resizeEnd": () => {
367
+ const lifted = liftedCaptionRef.current;
368
+ if (!lifted) return;
369
+ lifted.nodes[0].before(lifted.paragraph);
370
+ lifted.paragraph.append(...lifted.nodes);
371
+ liftedCaptionRef.current = null;
304
372
  },
305
373
  // Froala's shortcut dispatcher calls plugin commands unbound, so its own
306
374
  // Ctrl/Cmd+K throws instead of opening anything. Returning false cancels
@@ -1,10 +1,8 @@
1
1
  import type React from "react";
2
2
 
3
- import { FieldsBehavior, Modal } from "@ax/components";
4
- import type { IModal } from "@ax/types";
5
-
6
3
  import type { ExportFormat } from "@griddo/api-types";
7
-
4
+ import type { IModal } from "@ax/types";
5
+ import { FieldsBehavior, Modal } from "@ax/components";
8
6
  import type { IDownloadFormState } from "..";
9
7
 
10
8
  import * as S from "./style";
@@ -1,20 +1,19 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { connect } from "react-redux";
3
+ import { format, subDays } from "date-fns";
3
4
 
5
+ import type { ExportFormat, LogActivityExportRequest, LogActivityPaginationRequest } from "@griddo/api-types";
6
+ import type { ILogItemsByDay, ILogItemsByUser, IQueryValue, IRootState, IStructuredData } from "@ax/types";
4
7
  import { EmptyState, ErrorToast, FilterTagsBar, MainWrapper, SearchTagsBar, Select, TableList } from "@ax/components";
5
8
  import { activityLogActions } from "@ax/containers/ActivityLog";
6
9
  import { structuredDataActions } from "@ax/containers/StructuredData";
7
10
  import { useModal } from "@ax/hooks";
8
- import type { ILogItemsByDay, ILogItemsByUser, IQueryValue, IRootState, IStructuredData } from "@ax/types";
9
11
 
10
- import type { ExportFormat, LogActivityExportRequest, LogActivityPaginationRequest } from "@griddo/api-types";
11
- import { format, subDays } from "date-fns";
12
-
13
- import DownloadModal from "./DownloadModal";
14
12
  import { useFilterQuery } from "./hooks";
13
+ import TableHeader from "./TableHeader";
15
14
  import ItemLog from "./ItemLog";
16
15
  import ItemLogUser from "./ItemLogUser";
17
- import TableHeader from "./TableHeader";
16
+ import DownloadModal from "./DownloadModal";
18
17
 
19
18
  import * as S from "./style";
20
19