@sofereditor/react 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -264,6 +264,15 @@ interface UseEditorResult {
264
264
  cellIndex?: number;
265
265
  embed: ImageEmbed;
266
266
  } | null;
267
+ /**
268
+ * Passthrough of `UseEditorOptions.uploadImage` — `undefined` when not
269
+ * configured. Exposed so callers with the editor instance but not the
270
+ * original options (the `<Editor>` component's paste handler, notably) can
271
+ * upload an image without going through `insertImageFromFile` (which also
272
+ * inserts it — not always what's wanted, e.g. resolving `data:` embeds
273
+ * already inside a pasted slice before `insertSlice`).
274
+ */
275
+ uploadImage?: (file: File) => Promise<string>;
267
276
  }
268
277
  declare function useEditor(opts?: UseEditorOptions): UseEditorResult;
269
278
 
package/dist/index.d.ts CHANGED
@@ -264,6 +264,15 @@ interface UseEditorResult {
264
264
  cellIndex?: number;
265
265
  embed: ImageEmbed;
266
266
  } | null;
267
+ /**
268
+ * Passthrough of `UseEditorOptions.uploadImage` — `undefined` when not
269
+ * configured. Exposed so callers with the editor instance but not the
270
+ * original options (the `<Editor>` component's paste handler, notably) can
271
+ * upload an image without going through `insertImageFromFile` (which also
272
+ * inserts it — not always what's wanted, e.g. resolving `data:` embeds
273
+ * already inside a pasted slice before `insertSlice`).
274
+ */
275
+ uploadImage?: (file: File) => Promise<string>;
267
276
  }
268
277
  declare function useEditor(opts?: UseEditorOptions): UseEditorResult;
269
278
 
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  useState as useState10
9
9
  } from "react";
10
10
  import {
11
+ collapsedSelection as collapsedSelection2,
11
12
  deleteBackward,
12
13
  deleteForward,
13
14
  deleteSelection,
@@ -15,6 +16,7 @@ import {
15
16
  insertParagraph,
16
17
  insertSlice,
17
18
  insertText,
19
+ isEmbedAdjacentToCaret,
18
20
  serializeSelection,
19
21
  sliceToText,
20
22
  SOFER_MIME as SOFER_MIME2
@@ -297,7 +299,12 @@ function applyDomSelection(root, modelSel) {
297
299
  import { SOFER_MIME } from "@sofereditor/core";
298
300
 
299
301
  // src/htmlToSlice.ts
300
- import { MAX_LIST_LEVEL } from "@sofereditor/core";
302
+ import { isImageEmbed, MAX_LIST_LEVEL } from "@sofereditor/core";
303
+
304
+ // src/imageConstraints.ts
305
+ var MAX_INSERT_WIDTH = 600;
306
+
307
+ // src/htmlToSlice.ts
301
308
  function htmlToSlice(html) {
302
309
  if (!html || html.trim().length === 0) return null;
303
310
  let doc;
@@ -316,8 +323,10 @@ function htmlToSlice(html) {
316
323
  }
317
324
  if (blocks.length === 0) return null;
318
325
  normalizeListLevels(blocks);
319
- const temTexto = blocks.some((b) => b.text.trim().length > 0);
320
- if (!temTexto) return null;
326
+ const temConteudo = blocks.some(
327
+ (b) => b.text.trim().length > 0 || b.delta.some((op) => isImageEmbed(op.insert))
328
+ );
329
+ if (!temConteudo) return null;
321
330
  const blockLevel = blocks.length > 1 || blocks[0].type !== "paragraph";
322
331
  return { blocks, openStart: false, openEnd: false, ...blockLevel ? { blockLevel: true } : {} };
323
332
  }
@@ -428,6 +437,7 @@ function walkBlockLevel(container, marks, blocks) {
428
437
  break;
429
438
  }
430
439
  case "img":
440
+ loose.push(node);
431
441
  break;
432
442
  case "br":
433
443
  loose.push(node);
@@ -620,7 +630,11 @@ function walkInlineNode(node, marks, ops) {
620
630
  const el = node;
621
631
  const tag = el.tagName.toLowerCase();
622
632
  if (IGNORED_TAGS.has(tag) || isNamespacedTag(tag)) return;
623
- if (tag === "img") return;
633
+ if (tag === "img") {
634
+ const op = imageEmbedFromElement(el);
635
+ if (op) ops.push(op);
636
+ return;
637
+ }
624
638
  if (tag === "br") {
625
639
  ops.push({ insert: " " });
626
640
  return;
@@ -635,6 +649,73 @@ function walkInlineNode(node, marks, ops) {
635
649
  }
636
650
  for (const child of Array.from(el.childNodes)) walkInlineNode(child, nextMarks, ops);
637
651
  }
652
+ var MIN_EMBED_BYTES = 1024;
653
+ function decodedDataUrlByteLength(dataUrl) {
654
+ const commaIndex = dataUrl.indexOf(",");
655
+ if (commaIndex === -1) return 0;
656
+ const meta = dataUrl.slice(0, commaIndex);
657
+ const payload = dataUrl.slice(commaIndex + 1);
658
+ if (/;base64$/i.test(meta)) {
659
+ const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
660
+ return Math.max(0, Math.floor(payload.length * 3 / 4) - padding);
661
+ }
662
+ try {
663
+ return decodeURIComponent(payload).length;
664
+ } catch {
665
+ return payload.length;
666
+ }
667
+ }
668
+ function imageEmbedFromElement(el) {
669
+ const src = el.getAttribute("src");
670
+ if (!src || !src.startsWith("data:image/")) return null;
671
+ if (decodedDataUrlByteLength(src) < MIN_EMBED_BYTES) return null;
672
+ const style = parseStyle(el.getAttribute("style"));
673
+ const width = readImageDimension(el, "width", style);
674
+ const height = readImageDimension(el, "height", style);
675
+ if (width == null || height == null) return null;
676
+ let w = width;
677
+ let h = height;
678
+ if (w > MAX_INSERT_WIDTH) {
679
+ h = h * MAX_INSERT_WIDTH / w;
680
+ w = MAX_INSERT_WIDTH;
681
+ }
682
+ return { insert: { type: "image", src, width: Math.round(w), height: Math.round(h) } };
683
+ }
684
+ function readImageDimension(el, attr, style) {
685
+ const raw = el.getAttribute(attr);
686
+ if (raw && /^\d+(\.\d+)?$/.test(raw.trim())) {
687
+ const n = Number.parseFloat(raw);
688
+ if (Number.isFinite(n) && n > 0) return n;
689
+ }
690
+ const fromStyle = style[attr];
691
+ if (fromStyle) {
692
+ const n = cssLengthToPx(fromStyle);
693
+ if (n != null) return n;
694
+ }
695
+ return void 0;
696
+ }
697
+ function cssLengthToPx(raw) {
698
+ const v = raw.trim();
699
+ const m = /^(-?[\d.]+)\s*(px|pt|in|cm|mm)?$/i.exec(v);
700
+ if (!m) return void 0;
701
+ const num = Number.parseFloat(m[1]);
702
+ if (!Number.isFinite(num) || num <= 0) return void 0;
703
+ const unit = (m[2] ?? "px").toLowerCase();
704
+ switch (unit) {
705
+ case "px":
706
+ return num;
707
+ case "pt":
708
+ return num * (96 / 72);
709
+ case "in":
710
+ return num * 96;
711
+ case "cm":
712
+ return num * (96 / 2.54);
713
+ case "mm":
714
+ return num * (96 / 25.4);
715
+ default:
716
+ return void 0;
717
+ }
718
+ }
638
719
  function applyTagMarks(tag, marks) {
639
720
  switch (tag) {
640
721
  case "b":
@@ -902,6 +983,58 @@ function planPaste(cd) {
902
983
  return { kind: "none" };
903
984
  }
904
985
 
986
+ // src/resolvePastedImages.ts
987
+ import { isImageEmbed as isImageEmbed2 } from "@sofereditor/core";
988
+ function sliceHasDataImageEmbeds(slice) {
989
+ return slice.blocks.some(
990
+ (b) => b.delta.some((op) => isImageEmbed2(op.insert) && op.insert.src.startsWith("data:image/"))
991
+ );
992
+ }
993
+ async function resolvePastedImageUploads(slice, upload) {
994
+ if (!upload) return slice;
995
+ const blocks = await Promise.all(
996
+ slice.blocks.map(async (block) => {
997
+ let changed = false;
998
+ const delta = await Promise.all(
999
+ block.delta.map(async (op) => {
1000
+ if (!isImageEmbed2(op.insert) || !op.insert.src.startsWith("data:image/")) return op;
1001
+ try {
1002
+ const file = await dataUrlToFile(op.insert.src);
1003
+ const newSrc = await upload(file);
1004
+ changed = true;
1005
+ return { ...op, insert: { ...op.insert, src: newSrc } };
1006
+ } catch (err) {
1007
+ console.error(
1008
+ "[sofereditor] falha ao subir imagem colada para o storage \u2014 mantendo a imagem embutida (data:) nesta colagem",
1009
+ err
1010
+ );
1011
+ return op;
1012
+ }
1013
+ })
1014
+ );
1015
+ return changed ? { ...block, delta } : block;
1016
+ })
1017
+ );
1018
+ return { ...slice, blocks };
1019
+ }
1020
+ async function dataUrlToFile(dataUrl) {
1021
+ const blob = await fetch(dataUrl).then((r) => r.blob());
1022
+ const mime = blob.type;
1023
+ return new File([blob], `imagem-colada-${Date.now()}.${extensionForMime(mime)}`, { type: mime });
1024
+ }
1025
+ var MIME_EXTENSIONS = {
1026
+ "image/png": "png",
1027
+ "image/jpeg": "jpg",
1028
+ "image/jpg": "jpg",
1029
+ "image/gif": "gif",
1030
+ "image/webp": "webp",
1031
+ "image/bmp": "bmp",
1032
+ "image/svg+xml": "svg"
1033
+ };
1034
+ function extensionForMime(mime) {
1035
+ return MIME_EXTENSIONS[mime.toLowerCase()] ?? "png";
1036
+ }
1037
+
905
1038
  // src/BehindImageSelectAffordance.tsx
906
1039
  import {
907
1040
  useCallback,
@@ -2327,7 +2460,7 @@ import {
2327
2460
  import { Fragment as Fragment3, memo } from "react";
2328
2461
  import {
2329
2462
  BLANK_STYLE,
2330
- isImageEmbed,
2463
+ isImageEmbed as isImageEmbed3,
2331
2464
  splitUnderscoreRuns
2332
2465
  } from "@sofereditor/core";
2333
2466
  import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
@@ -2345,7 +2478,7 @@ function renderInline(delta, keyPrefix) {
2345
2478
  const key = `${keyPrefix}-${offset}`;
2346
2479
  body.push(/* @__PURE__ */ jsx9(Fragment3, { children: wrap(text, op.attributes) }, key));
2347
2480
  offset += text.length;
2348
- } else if (isImageEmbed(op.insert)) {
2481
+ } else if (isImageEmbed3(op.insert)) {
2349
2482
  const key = `${keyPrefix}-${offset}`;
2350
2483
  const layout = op.insert.layout ?? "inline";
2351
2484
  const commentMarkId = op.attributes?.comment?.markId;
@@ -3460,7 +3593,7 @@ import {
3460
3593
  insertTable as cmdInsertTable,
3461
3594
  insertTableColumn as cmdInsertColumn,
3462
3595
  insertTableRow as cmdInsertRow,
3463
- isImageEmbed as isImageEmbed2,
3596
+ isImageEmbed as isImageEmbed4,
3464
3597
  isMarkUniformInRange,
3465
3598
  moveToNextCell as cmdMoveNextCell,
3466
3599
  moveToPrevCell as cmdMovePrevCell,
@@ -3475,7 +3608,6 @@ import {
3475
3608
  toggleList as cmdToggleList,
3476
3609
  toggleMark as cmdToggleMark
3477
3610
  } from "@sofereditor/core";
3478
- var MAX_INSERT_WIDTH = 600;
3479
3611
  function useEditor(opts = {}) {
3480
3612
  const doc = useMemo3(() => opts.document ?? new EditorDocument(), [opts.document]);
3481
3613
  const history = useMemo3(() => new EditorHistory(doc), [doc]);
@@ -3862,7 +3994,7 @@ function useEditor(opts = {}) {
3862
3994
  cursor += op.insert.length;
3863
3995
  continue;
3864
3996
  }
3865
- if (cursor === start && isImageEmbed2(op.insert)) {
3997
+ if (cursor === start && isImageEmbed4(op.insert)) {
3866
3998
  return {
3867
3999
  blockIndex: sel.anchor.blockIndex,
3868
4000
  cellIndex: sel.anchor.cellIndex,
@@ -3995,7 +4127,8 @@ function useEditor(opts = {}) {
3995
4127
  resolvePageConfigRequest,
3996
4128
  imageCaptionRequest,
3997
4129
  requestImageCaption,
3998
- resolveImageCaptionRequest
4130
+ resolveImageCaptionRequest,
4131
+ uploadImage: opts.uploadImage
3999
4132
  };
4000
4133
  }
4001
4134
  function readAsDataURL(file) {
@@ -4042,6 +4175,7 @@ function Editor({
4042
4175
  const { doc, history, snapshot, getSelection, setSelection } = editor;
4043
4176
  const rootRef = useRef11(null);
4044
4177
  const isComposingRef = useRef11(false);
4178
+ const pasteInFlightRef = useRef11(false);
4045
4179
  const [hoveredBehind, setHoveredBehind] = useState10(null);
4046
4180
  const hoveredBehindRef = useRef11(null);
4047
4181
  const [hoveredLink, setHoveredLink] = useState10(null);
@@ -4484,9 +4618,48 @@ function Editor({
4484
4618
  e.preventDefault();
4485
4619
  switch (plan.kind) {
4486
4620
  case "sofer":
4487
- case "html":
4488
4621
  insertSlice(ctx, plan.slice);
4489
4622
  return;
4623
+ case "html": {
4624
+ if (!sliceHasDataImageEmbeds(plan.slice)) {
4625
+ insertSlice(ctx, plan.slice);
4626
+ return;
4627
+ }
4628
+ if (pasteInFlightRef.current) {
4629
+ console.warn(
4630
+ "[sofereditor] colagem com imagem ainda subindo \u2014 descartando esta colagem concorrente para n\xE3o corromper a posi\xE7\xE3o de inser\xE7\xE3o"
4631
+ );
4632
+ return;
4633
+ }
4634
+ pasteInFlightRef.current = true;
4635
+ const capturedSelection = ctx.getSelection();
4636
+ void (async () => {
4637
+ try {
4638
+ const resolved = await resolvePastedImageUploads(plan.slice, editorRef.current.uploadImage);
4639
+ const liveSelection = ctxRef.current.getSelection();
4640
+ const userMovedAway = !selectionsEqual(liveSelection, capturedSelection);
4641
+ const { blockIndex, cellIndex } = capturedSelection.focus;
4642
+ const targetText = ctxRef.current.doc.textAt(blockIndex, cellIndex);
4643
+ let insertAt = capturedSelection;
4644
+ if (!targetText) {
4645
+ console.error(
4646
+ "[sofereditor] bloco de destino da colagem sumiu durante o upload da imagem \u2014 inserindo no fim do documento em vez de descartar a colagem"
4647
+ );
4648
+ const lastIndex = Math.max(0, ctxRef.current.doc.blockCount() - 1);
4649
+ const lastText = ctxRef.current.doc.getBlockText(lastIndex);
4650
+ insertAt = collapsedSelection2({ blockIndex: lastIndex, offset: lastText ? lastText.length : 0 });
4651
+ }
4652
+ ctxRef.current.setSelection(insertAt);
4653
+ insertSlice(ctxRef.current, resolved);
4654
+ if (userMovedAway) {
4655
+ ctxRef.current.setSelection(liveSelection);
4656
+ }
4657
+ } finally {
4658
+ pasteInFlightRef.current = false;
4659
+ }
4660
+ })();
4661
+ return;
4662
+ }
4490
4663
  case "images":
4491
4664
  void (async () => {
4492
4665
  for (const f of plan.files) await editorRef.current.insertImageFromFile(f);
@@ -4539,6 +4712,17 @@ function Editor({
4539
4712
  }
4540
4713
  return;
4541
4714
  }
4715
+ if (!mod && (e.key === "Backspace" || e.key === "Delete")) {
4716
+ if (isComposingRef.current) return;
4717
+ const direction = e.key === "Backspace" ? "backward" : "forward";
4718
+ const shouldIntercept = ed.getSelectedEmbed() != null || isEmbedAdjacentToCaret(ctxRef.current, direction);
4719
+ if (shouldIntercept) {
4720
+ e.preventDefault();
4721
+ if (direction === "backward") deleteBackward(ctxRef.current);
4722
+ else deleteForward(ctxRef.current);
4723
+ }
4724
+ return;
4725
+ }
4542
4726
  if (!mod) return;
4543
4727
  const key = e.key.toLowerCase();
4544
4728
  if (!e.shiftKey && !e.altKey && key === "z") {