@tensor_1/react 2.25.0 → 2.26.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
@@ -92,7 +92,9 @@ Markdown cannot represent everything a user can type. When the editor re-seriali
92
92
  - Reference definitions (`[id]: url`) are kept. If their surrounding block is deleted, they move to the end.
93
93
 
94
94
  Editor props: `features` (`full` / `comment` / `template`), `headingLevels` (limits the toolbar and input rules;
95
- existing headings at other levels keep their level), `onUploadImage` (enables the image button and image paste/drop),
95
+ existing headings at other levels keep their level), `onUploadImage` (enables the image button and image paste/drop in
96
+ every preset), `imagesUnavailableReason` (status-line text when an image is pasted or dropped while images are off;
97
+ default label `imagesUnsupported`, "Images can't be added here."; a paste that also carries text stays a text paste),
96
98
  `isSafeUrl`, `resolveImageSrc` (its return value is used verbatim), `onSubmitShortcut` (Ctrl/Cmd+Enter), `emoji`,
97
99
  `emojiImageBaseUrl`, `maxLength` (counter; over the limit sets `aria-invalid`), `placeholder`, `disabled`, `readOnly`,
98
100
  `labels` (every string, placeholders `{count}` `{max}`), `emojiPickerLabels`, `emojiLocale`, `id`, `name` (adds a
@@ -227,6 +229,18 @@ Radial mode still shortens long labels to fit its ring, but every node carries i
227
229
 
228
230
  Ships **ESM + CJS + types**, with per-file `"use client"` so it's safe under React Server Components. Part of the **[UIx v2 styleguide](https://github.com/COx-ORG-H/UIx)**.
229
231
 
232
+ ### Drawer closes on a backdrop click
233
+
234
+ `Drawer` closes on a click on the dimmed backdrop, as well as on Escape and its close button, and calls the same
235
+ `onClose` (TENSOR HAR-547). `Peek` already did; both now share one handler. On a drawer that holds unsaved form input,
236
+ pass `dismissOnBackdrop={false}` so a stray click can't discard it; Escape and the close button still close. On a
237
+ phone the panel stops 2em + 6px short of the left edge (the browser's modal-dialog `max-width`), and a tap on that
238
+ strip closes it too.
239
+
240
+ ```tsx
241
+ <Drawer open={open} onClose={close} title="Edit user" dismissOnBackdrop={!dirty}>…</Drawer>
242
+ ```
243
+
230
244
  ### InfoTip and `help` slots
231
245
 
232
246
  A small ? next to a title or label opens a plain-text explanation. Use it for what a page, section or field *means*. Keep instructions the user needs while typing ("8+ characters", "ISO date") visible in `hint`.
@@ -6,10 +6,16 @@ interface DrawerProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
6
6
  closeLabel?: string;
7
7
  open: boolean;
8
8
  onClose?: () => void;
9
+ /**
10
+ * A click on the dimmed backdrop calls `onClose`, like Escape and the close button
11
+ * (TENSOR HAR-547). Pass `false` on a drawer that holds unsaved form input, so a stray click
12
+ * can't discard it; Escape and the close button still close. Default `true`.
13
+ */
14
+ dismissOnBackdrop?: boolean;
9
15
  title?: ReactNode;
10
16
  children?: ReactNode;
11
17
  footer?: ReactNode;
12
18
  }
13
- declare function Drawer({ open, onClose, title, children, footer, closeLabel, className, ...rest }: DrawerProps): react.JSX.Element;
19
+ declare function Drawer({ open, onClose, dismissOnBackdrop, title, children, footer, closeLabel, className, onClick, ...rest }: DrawerProps): react.JSX.Element;
14
20
 
15
21
  export { Drawer, type DrawerProps };
@@ -3,11 +3,12 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useId } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { useDialog } from "../hooks/useDialog.js";
6
+ import { backdropDismiss } from "../hooks/backdropDismiss.js";
6
7
  const CloseIcon = () => /* @__PURE__ */ jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) });
7
- function Drawer({ open, onClose, title, children, footer, closeLabel = "Close drawer", className, ...rest }) {
8
+ function Drawer({ open, onClose, dismissOnBackdrop = true, title, children, footer, closeLabel = "Close drawer", className, onClick, ...rest }) {
8
9
  const ref = useDialog(open, onClose);
9
10
  const titleId = useId();
10
- return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-drawer", className), "aria-labelledby": title ? titleId : void 0, ...rest, children: [
11
+ return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-drawer", className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: backdropDismiss(onClose, onClick, dismissOnBackdrop), children: [
11
12
  (title != null || onClose) && /* @__PURE__ */ jsxs("div", { className: "uix-drawer__header", children: [
12
13
  title && /* @__PURE__ */ jsx("h2", { id: titleId, style: { fontWeight: 600, fontSize: "var(--uix-text-h3)", fontFamily: "inherit", letterSpacing: "inherit", lineHeight: "inherit" }, children: title }),
13
14
  onClose && /* @__PURE__ */ jsx(
@@ -3,6 +3,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useId } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { useDialog } from "../hooks/useDialog.js";
6
+ import { backdropDismiss } from "../hooks/backdropDismiss.js";
6
7
  const CloseIcon = () => /* @__PURE__ */ jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) });
7
8
  const ChevronUp = () => /* @__PURE__ */ jsx("svg", { width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M2 8l4-4 4 4" }) });
8
9
  const ChevronDown = () => /* @__PURE__ */ jsx("svg", { width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M2 4l4 4 4-4" }) });
@@ -10,15 +11,7 @@ function Peek({ open, onClose, title, children, footer, onNavPrev, onNavNext, hi
10
11
  const ref = useDialog(open, onClose);
11
12
  const hasNav = onNavPrev != null || onNavNext != null;
12
13
  const titleId = useId();
13
- const onBackdropClick = (e) => {
14
- onClick?.(e);
15
- if (!onClose || e.target !== e.currentTarget) return;
16
- const r = e.currentTarget.getBoundingClientRect();
17
- if (r.width === 0) return;
18
- const outside = e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
19
- if (outside) onClose();
20
- };
21
- return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-peek", className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: onBackdropClick, children: [
14
+ return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-peek", className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: backdropDismiss(onClose, onClick), children: [
22
15
  /* @__PURE__ */ jsxs("div", { className: "uix-peek__header", children: [
23
16
  hasNav && /* @__PURE__ */ jsxs("div", { className: "uix-peek__nav", children: [
24
17
  /* @__PURE__ */ jsx("button", { className: "uix-peek__navbtn", onClick: () => onNavPrev?.(), "aria-disabled": onNavPrev ? void 0 : true, "aria-label": previousLabel, children: /* @__PURE__ */ jsx(ChevronUp, {}) }),
@@ -39,6 +39,8 @@ interface RichTextLabels {
39
39
  image: string;
40
40
  imageUploading: string;
41
41
  imageFailed: string;
42
+ /** Shown when an image is pasted or dropped where images are off (default for `imagesUnavailableReason`). */
43
+ imagesUnsupported: string;
42
44
  emoji: string;
43
45
  /** Accessible name of the `:shortcode` suggestion list. */
44
46
  emojiSuggestions: string;
@@ -65,11 +67,17 @@ interface RichTextEditorProps {
65
67
  features?: RichTextFeatures;
66
68
  /** Heading levels the toolbar and input rules offer. Default `[1, 2, 3]`. */
67
69
  headingLevels?: ReadonlyArray<RichTextHeadingLevel>;
68
- /** Enables the image button (preset `full`), and image paste/drop. */
70
+ /** Enables image paste/drop and the toolbar image button, in every preset. */
69
71
  onUploadImage?: (file: File) => Promise<{
70
72
  src: string;
71
73
  alt: string;
72
74
  }>;
75
+ /**
76
+ * Shown in the status line when an image is pasted or dropped while images are off (no
77
+ * `onUploadImage`), e.g. "Attach files to the incident instead.". Nothing is inserted.
78
+ * Default: the `imagesUnsupported` label.
79
+ */
80
+ imagesUnavailableReason?: string;
73
81
  /** Link policy. Default: http(s), mailto and same-app paths. */
74
82
  isSafeUrl?: (url: string) => boolean;
75
83
  /** Image policy: the URL to display, or `null` to show the image as a link chip. */
@@ -59,6 +59,7 @@ const DEFAULT_RICH_TEXT_LABELS = {
59
59
  image: "Image",
60
60
  imageUploading: "Uploading image\u2026",
61
61
  imageFailed: "The image could not be added.",
62
+ imagesUnsupported: "Images can't be added here.",
62
63
  emoji: "Emoji",
63
64
  emojiSuggestions: "Emoji suggestions",
64
65
  undo: "Undo",
@@ -70,8 +71,9 @@ const DEFAULT_RICH_TEXT_LABELS = {
70
71
  const COMMON = ["bold", "italic", "strike", "code", "bulletList", "orderedList", "taskList", "blockquote", "codeBlock", "link", "emoji", "history", "source"];
71
72
  const PRESETS = {
72
73
  full: /* @__PURE__ */ new Set([...COMMON, "heading", "horizontalRule", "table", "image"]),
73
- comment: new Set(COMMON),
74
- template: /* @__PURE__ */ new Set([...COMMON, "heading", "horizontalRule", "table"])
74
+ // Images in every preset once the consumer supplies onUploadImage (TENSOR HAR-749).
75
+ comment: /* @__PURE__ */ new Set([...COMMON, "image"]),
76
+ template: /* @__PURE__ */ new Set([...COMMON, "heading", "horizontalRule", "table", "image"])
75
77
  };
76
78
  const emojiSuggestionKey = new PluginKey("uixEmojiSuggestion");
77
79
  const SHORTCODE_RE = /:([a-zA-Z0-9_+-]+):$/;
@@ -187,7 +189,8 @@ function RichTextEditor(props) {
187
189
  minRows,
188
190
  className,
189
191
  onBlur,
190
- emojiImageBaseUrl
192
+ emojiImageBaseUrl,
193
+ imagesUnavailableReason
191
194
  } = props;
192
195
  const emojiBase = useMemo(() => normalizeEmojiImageBaseUrl(emojiImageBaseUrl), [emojiImageBaseUrl]);
193
196
  const renderEmoji = useEmojiRenderer(emojiImageBaseUrl);
@@ -199,7 +202,7 @@ function RichTextEditor(props) {
199
202
  const linkPopoverId = `${uid}-link`;
200
203
  const controls = PRESETS[features];
201
204
  const has = (control) => controls.has(control) && (control !== "emoji" || emoji) && (control !== "image" || !!onUploadImage);
202
- const latest = useRef({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, placeholder, imagesOn: false });
205
+ const latest = useRef({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, placeholder, imagesOn: false, imagesOffText: "" });
203
206
  latest.current = {
204
207
  onChange,
205
208
  onSubmitShortcut,
@@ -208,7 +211,8 @@ function RichTextEditor(props) {
208
211
  resolveImageSrc,
209
212
  onUploadImage,
210
213
  placeholder,
211
- imagesOn: controls.has("image") && !!onUploadImage
214
+ imagesOn: controls.has("image") && !!onUploadImage,
215
+ imagesOffText: imagesUnavailableReason?.trim() || labels.imagesUnsupported
212
216
  };
213
217
  const lastValue = useRef(value);
214
218
  const origin = useRef(null);
@@ -431,22 +435,31 @@ function RichTextEditor(props) {
431
435
  shouldRerenderOnTransaction: false,
432
436
  editable: !disabled && !readOnly,
433
437
  editorProps: {
438
+ // An image file pasted or dropped where images are off used to vanish silently; now the
439
+ // status line says so (TENSOR HAR-749). A paste that also carries text is a text paste:
440
+ // Excel, Word and Docs add a PNG rendering of the copied cells or text, which must not
441
+ // replace (or block) the text itself.
434
442
  handlePaste: (_view, event) => {
435
- const files = Array.from(event.clipboardData?.files ?? []).filter((f) => f.type.startsWith("image/"));
436
- if (!files.length || !latest.current.imagesOn || !editorRef.current) return false;
437
- files.forEach((file) => void uploadImage(editorRef.current, file));
443
+ const data = event.clipboardData;
444
+ const files = Array.from(data?.files ?? []).filter((f) => f.type.startsWith("image/"));
445
+ if (!files.length || !editorRef.current || data?.getData("text/plain").trim()) return false;
446
+ event.preventDefault();
447
+ if (!latest.current.imagesOn) setStatus({ kind: "notice", text: latest.current.imagesOffText });
448
+ else files.forEach((file) => void uploadImage(editorRef.current, file));
438
449
  return true;
439
450
  },
440
451
  handleDrop: (_view, event) => {
441
452
  const files = Array.from(event.dataTransfer?.files ?? []).filter((f) => f.type.startsWith("image/"));
442
- if (!files.length || !latest.current.imagesOn || !editorRef.current) return false;
453
+ if (!files.length || !editorRef.current) return false;
443
454
  event.preventDefault();
444
- files.forEach((file) => void uploadImage(editorRef.current, file));
455
+ if (!latest.current.imagesOn) setStatus({ kind: "notice", text: latest.current.imagesOffText });
456
+ else files.forEach((file) => void uploadImage(editorRef.current, file));
445
457
  return true;
446
458
  }
447
459
  },
448
460
  onCreate: ({ editor: ed }) => load(ed, lastValue.current),
449
461
  onUpdate: ({ editor: ed }) => {
462
+ setStatus((s) => s?.kind === "notice" ? null : s);
450
463
  if (!origin.current) return;
451
464
  emit(writeMarkdown(ed.getJSON(), origin.current, managerOf(ed)));
452
465
  },
@@ -744,18 +757,19 @@ function RichTextEditor(props) {
744
757
  ) }) : null,
745
758
  trailing.length ? /* @__PURE__ */ jsx("div", { className: "uix-rich-text__group uix-rich-text__group--end", role: "group", children: trailing.map(renderTool) }) : null,
746
759
  has("link") ? /* @__PURE__ */ jsx(Popover, { id: linkPopoverId, anchor: linkButtonRef, className: "uix-rich-text__link", role: "dialog", "aria-label": labels.link, children: /* @__PURE__ */ jsxs(
747
- "form",
760
+ "div",
748
761
  {
749
762
  className: "uix-rich-text__link-form",
750
- onSubmit: (event) => {
751
- event.preventDefault();
752
- applyLink();
753
- },
754
763
  onKeyDown: (event) => {
755
764
  if (event.key === "Escape") {
756
765
  event.preventDefault();
757
766
  closeLink();
758
767
  }
768
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
769
+ if (event.key === "Enter" && event.target === linkInputRef.current) {
770
+ event.preventDefault();
771
+ applyLink();
772
+ }
759
773
  },
760
774
  children: [
761
775
  /* @__PURE__ */ jsxs("div", { className: "uix-field", children: [
@@ -793,7 +807,7 @@ function RichTextEditor(props) {
793
807
  children: labels.linkRemove
794
808
  }
795
809
  ) : null,
796
- /* @__PURE__ */ jsx("button", { type: "submit", className: "uix-btn uix-btn--primary uix-btn--sm", children: labels.linkApply })
810
+ /* @__PURE__ */ jsx("button", { type: "button", className: "uix-btn uix-btn--primary uix-btn--sm", onClick: applyLink, children: labels.linkApply })
797
811
  ] })
798
812
  ]
799
813
  }
@@ -0,0 +1,7 @@
1
+ import { MouseEventHandler, MouseEvent } from 'react';
2
+
3
+ declare function isBackdropClick(e: MouseEvent<HTMLDialogElement>): boolean;
4
+ /** The dialog's onClick: runs the consumer's own onClick first, then closes on a backdrop click. */
5
+ declare function backdropDismiss(onClose: (() => void) | undefined, onClick?: MouseEventHandler<HTMLDialogElement>, enabled?: boolean): MouseEventHandler<HTMLDialogElement>;
6
+
7
+ export { backdropDismiss, isBackdropClick };
@@ -0,0 +1,16 @@
1
+ function isBackdropClick(e) {
2
+ if (e.target !== e.currentTarget) return false;
3
+ const r = e.currentTarget.getBoundingClientRect();
4
+ if (r.width === 0) return false;
5
+ return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
6
+ }
7
+ function backdropDismiss(onClose, onClick, enabled = true) {
8
+ return (e) => {
9
+ onClick?.(e);
10
+ if (enabled && onClose && isBackdropClick(e)) onClose();
11
+ };
12
+ }
13
+ export {
14
+ backdropDismiss,
15
+ isBackdropClick
16
+ };
package/dist/index.cjs CHANGED
@@ -1791,12 +1791,28 @@ function PromptDialog({ open, title, description, inputLabel, defaultValue = "",
1791
1791
 
1792
1792
  // src/components/Drawer.tsx
1793
1793
  var import_react19 = require("react");
1794
+
1795
+ // src/hooks/backdropDismiss.ts
1796
+ function isBackdropClick(e) {
1797
+ if (e.target !== e.currentTarget) return false;
1798
+ const r = e.currentTarget.getBoundingClientRect();
1799
+ if (r.width === 0) return false;
1800
+ return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
1801
+ }
1802
+ function backdropDismiss(onClose, onClick, enabled = true) {
1803
+ return (e) => {
1804
+ onClick?.(e);
1805
+ if (enabled && onClose && isBackdropClick(e)) onClose();
1806
+ };
1807
+ }
1808
+
1809
+ // src/components/Drawer.tsx
1794
1810
  var import_jsx_runtime16 = require("react/jsx-runtime");
1795
1811
  var CloseIcon2 = () => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("path", { d: "M4 4l8 8M12 4l-8 8" }) });
1796
- function Drawer({ open, onClose, title, children, footer, closeLabel = "Close drawer", className, ...rest }) {
1812
+ function Drawer({ open, onClose, dismissOnBackdrop = true, title, children, footer, closeLabel = "Close drawer", className, onClick, ...rest }) {
1797
1813
  const ref = useDialog(open, onClose);
1798
1814
  const titleId = (0, import_react19.useId)();
1799
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("dialog", { ref, className: cx("uix-drawer", className), "aria-labelledby": title ? titleId : void 0, ...rest, children: [
1815
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("dialog", { ref, className: cx("uix-drawer", className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: backdropDismiss(onClose, onClick, dismissOnBackdrop), children: [
1800
1816
  (title != null || onClose) && /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "uix-drawer__header", children: [
1801
1817
  title && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h2", { id: titleId, style: { fontWeight: 600, fontSize: "var(--uix-text-h3)", fontFamily: "inherit", letterSpacing: "inherit", lineHeight: "inherit" }, children: title }),
1802
1818
  onClose && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
@@ -1824,15 +1840,7 @@ function Peek({ open, onClose, title, children, footer, onNavPrev, onNavNext, hi
1824
1840
  const ref = useDialog(open, onClose);
1825
1841
  const hasNav = onNavPrev != null || onNavNext != null;
1826
1842
  const titleId = (0, import_react20.useId)();
1827
- const onBackdropClick = (e) => {
1828
- onClick?.(e);
1829
- if (!onClose || e.target !== e.currentTarget) return;
1830
- const r = e.currentTarget.getBoundingClientRect();
1831
- if (r.width === 0) return;
1832
- const outside = e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
1833
- if (outside) onClose();
1834
- };
1835
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("dialog", { ref, className: cx("uix-peek", className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: onBackdropClick, children: [
1843
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("dialog", { ref, className: cx("uix-peek", className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: backdropDismiss(onClose, onClick), children: [
1836
1844
  /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "uix-peek__header", children: [
1837
1845
  hasNav && /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "uix-peek__nav", children: [
1838
1846
  /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("button", { className: "uix-peek__navbtn", onClick: () => onNavPrev?.(), "aria-disabled": onNavPrev ? void 0 : true, "aria-label": previousLabel, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(ChevronUp, {}) }),
package/dist/index.d.cts CHANGED
@@ -453,11 +453,17 @@ interface DrawerProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
453
453
  closeLabel?: string;
454
454
  open: boolean;
455
455
  onClose?: () => void;
456
+ /**
457
+ * A click on the dimmed backdrop calls `onClose`, like Escape and the close button
458
+ * (TENSOR HAR-547). Pass `false` on a drawer that holds unsaved form input, so a stray click
459
+ * can't discard it; Escape and the close button still close. Default `true`.
460
+ */
461
+ dismissOnBackdrop?: boolean;
456
462
  title?: ReactNode;
457
463
  children?: ReactNode;
458
464
  footer?: ReactNode;
459
465
  }
460
- declare function Drawer({ open, onClose, title, children, footer, closeLabel, className, ...rest }: DrawerProps): react.JSX.Element;
466
+ declare function Drawer({ open, onClose, dismissOnBackdrop, title, children, footer, closeLabel, className, onClick, ...rest }: DrawerProps): react.JSX.Element;
461
467
 
462
468
  interface PeekProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
463
469
  /** TENSOR RX-125 (UIX-04): translatable; English default. */
@@ -1674,6 +1674,7 @@ var DEFAULT_RICH_TEXT_LABELS = {
1674
1674
  image: "Image",
1675
1675
  imageUploading: "Uploading image\u2026",
1676
1676
  imageFailed: "The image could not be added.",
1677
+ imagesUnsupported: "Images can't be added here.",
1677
1678
  emoji: "Emoji",
1678
1679
  emojiSuggestions: "Emoji suggestions",
1679
1680
  undo: "Undo",
@@ -1685,8 +1686,9 @@ var DEFAULT_RICH_TEXT_LABELS = {
1685
1686
  var COMMON = ["bold", "italic", "strike", "code", "bulletList", "orderedList", "taskList", "blockquote", "codeBlock", "link", "emoji", "history", "source"];
1686
1687
  var PRESETS = {
1687
1688
  full: /* @__PURE__ */ new Set([...COMMON, "heading", "horizontalRule", "table", "image"]),
1688
- comment: new Set(COMMON),
1689
- template: /* @__PURE__ */ new Set([...COMMON, "heading", "horizontalRule", "table"])
1689
+ // Images in every preset once the consumer supplies onUploadImage (TENSOR HAR-749).
1690
+ comment: /* @__PURE__ */ new Set([...COMMON, "image"]),
1691
+ template: /* @__PURE__ */ new Set([...COMMON, "heading", "horizontalRule", "table", "image"])
1690
1692
  };
1691
1693
  var emojiSuggestionKey = new import_state.PluginKey("uixEmojiSuggestion");
1692
1694
  var SHORTCODE_RE = /:([a-zA-Z0-9_+-]+):$/;
@@ -1802,7 +1804,8 @@ function RichTextEditor(props) {
1802
1804
  minRows,
1803
1805
  className,
1804
1806
  onBlur,
1805
- emojiImageBaseUrl
1807
+ emojiImageBaseUrl,
1808
+ imagesUnavailableReason
1806
1809
  } = props;
1807
1810
  const emojiBase = (0, import_react7.useMemo)(() => normalizeEmojiImageBaseUrl(emojiImageBaseUrl), [emojiImageBaseUrl]);
1808
1811
  const renderEmoji = useEmojiRenderer(emojiImageBaseUrl);
@@ -1814,7 +1817,7 @@ function RichTextEditor(props) {
1814
1817
  const linkPopoverId = `${uid}-link`;
1815
1818
  const controls = PRESETS[features];
1816
1819
  const has = (control) => controls.has(control) && (control !== "emoji" || emoji) && (control !== "image" || !!onUploadImage);
1817
- const latest = (0, import_react7.useRef)({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, placeholder, imagesOn: false });
1820
+ const latest = (0, import_react7.useRef)({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, placeholder, imagesOn: false, imagesOffText: "" });
1818
1821
  latest.current = {
1819
1822
  onChange,
1820
1823
  onSubmitShortcut,
@@ -1823,7 +1826,8 @@ function RichTextEditor(props) {
1823
1826
  resolveImageSrc,
1824
1827
  onUploadImage,
1825
1828
  placeholder,
1826
- imagesOn: controls.has("image") && !!onUploadImage
1829
+ imagesOn: controls.has("image") && !!onUploadImage,
1830
+ imagesOffText: imagesUnavailableReason?.trim() || labels.imagesUnsupported
1827
1831
  };
1828
1832
  const lastValue = (0, import_react7.useRef)(value);
1829
1833
  const origin = (0, import_react7.useRef)(null);
@@ -2046,22 +2050,31 @@ function RichTextEditor(props) {
2046
2050
  shouldRerenderOnTransaction: false,
2047
2051
  editable: !disabled && !readOnly,
2048
2052
  editorProps: {
2053
+ // An image file pasted or dropped where images are off used to vanish silently; now the
2054
+ // status line says so (TENSOR HAR-749). A paste that also carries text is a text paste:
2055
+ // Excel, Word and Docs add a PNG rendering of the copied cells or text, which must not
2056
+ // replace (or block) the text itself.
2049
2057
  handlePaste: (_view, event) => {
2050
- const files = Array.from(event.clipboardData?.files ?? []).filter((f) => f.type.startsWith("image/"));
2051
- if (!files.length || !latest.current.imagesOn || !editorRef.current) return false;
2052
- files.forEach((file) => void uploadImage(editorRef.current, file));
2058
+ const data = event.clipboardData;
2059
+ const files = Array.from(data?.files ?? []).filter((f) => f.type.startsWith("image/"));
2060
+ if (!files.length || !editorRef.current || data?.getData("text/plain").trim()) return false;
2061
+ event.preventDefault();
2062
+ if (!latest.current.imagesOn) setStatus({ kind: "notice", text: latest.current.imagesOffText });
2063
+ else files.forEach((file) => void uploadImage(editorRef.current, file));
2053
2064
  return true;
2054
2065
  },
2055
2066
  handleDrop: (_view, event) => {
2056
2067
  const files = Array.from(event.dataTransfer?.files ?? []).filter((f) => f.type.startsWith("image/"));
2057
- if (!files.length || !latest.current.imagesOn || !editorRef.current) return false;
2068
+ if (!files.length || !editorRef.current) return false;
2058
2069
  event.preventDefault();
2059
- files.forEach((file) => void uploadImage(editorRef.current, file));
2070
+ if (!latest.current.imagesOn) setStatus({ kind: "notice", text: latest.current.imagesOffText });
2071
+ else files.forEach((file) => void uploadImage(editorRef.current, file));
2060
2072
  return true;
2061
2073
  }
2062
2074
  },
2063
2075
  onCreate: ({ editor: ed }) => load(ed, lastValue.current),
2064
2076
  onUpdate: ({ editor: ed }) => {
2077
+ setStatus((s) => s?.kind === "notice" ? null : s);
2065
2078
  if (!origin.current) return;
2066
2079
  emit(writeMarkdown(ed.getJSON(), origin.current, managerOf(ed)));
2067
2080
  },
@@ -2359,18 +2372,19 @@ function RichTextEditor(props) {
2359
2372
  ) }) : null,
2360
2373
  trailing.length ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "uix-rich-text__group uix-rich-text__group--end", role: "group", children: trailing.map(renderTool) }) : null,
2361
2374
  has("link") ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Popover, { id: linkPopoverId, anchor: linkButtonRef, className: "uix-rich-text__link", role: "dialog", "aria-label": labels.link, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
2362
- "form",
2375
+ "div",
2363
2376
  {
2364
2377
  className: "uix-rich-text__link-form",
2365
- onSubmit: (event) => {
2366
- event.preventDefault();
2367
- applyLink();
2368
- },
2369
2378
  onKeyDown: (event) => {
2370
2379
  if (event.key === "Escape") {
2371
2380
  event.preventDefault();
2372
2381
  closeLink();
2373
2382
  }
2383
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
2384
+ if (event.key === "Enter" && event.target === linkInputRef.current) {
2385
+ event.preventDefault();
2386
+ applyLink();
2387
+ }
2374
2388
  },
2375
2389
  children: [
2376
2390
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "uix-field", children: [
@@ -2408,7 +2422,7 @@ function RichTextEditor(props) {
2408
2422
  children: labels.linkRemove
2409
2423
  }
2410
2424
  ) : null,
2411
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "submit", className: "uix-btn uix-btn--primary uix-btn--sm", children: labels.linkApply })
2425
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("button", { type: "button", className: "uix-btn uix-btn--primary uix-btn--sm", onClick: applyLink, children: labels.linkApply })
2412
2426
  ] })
2413
2427
  ]
2414
2428
  }
@@ -38,6 +38,8 @@ interface RichTextLabels {
38
38
  image: string;
39
39
  imageUploading: string;
40
40
  imageFailed: string;
41
+ /** Shown when an image is pasted or dropped where images are off (default for `imagesUnavailableReason`). */
42
+ imagesUnsupported: string;
41
43
  emoji: string;
42
44
  /** Accessible name of the `:shortcode` suggestion list. */
43
45
  emojiSuggestions: string;
@@ -64,11 +66,17 @@ interface RichTextEditorProps {
64
66
  features?: RichTextFeatures;
65
67
  /** Heading levels the toolbar and input rules offer. Default `[1, 2, 3]`. */
66
68
  headingLevels?: ReadonlyArray<RichTextHeadingLevel>;
67
- /** Enables the image button (preset `full`), and image paste/drop. */
69
+ /** Enables image paste/drop and the toolbar image button, in every preset. */
68
70
  onUploadImage?: (file: File) => Promise<{
69
71
  src: string;
70
72
  alt: string;
71
73
  }>;
74
+ /**
75
+ * Shown in the status line when an image is pasted or dropped while images are off (no
76
+ * `onUploadImage`), e.g. "Attach files to the incident instead.". Nothing is inserted.
77
+ * Default: the `imagesUnsupported` label.
78
+ */
79
+ imagesUnavailableReason?: string;
72
80
  /** Link policy. Default: http(s), mailto and same-app paths. */
73
81
  isSafeUrl?: (url: string) => boolean;
74
82
  /** Image policy: the URL to display, or `null` to show the image as a link chip. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensor_1/react",
3
- "version": "2.25.0",
3
+ "version": "2.26.1",
4
4
  "description": "Thin React wrappers over the UIx v2 CSS component library.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",