@meistrari/minuta-editor 1.0.1 → 1.1.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.
@@ -70,6 +70,8 @@ function parseStyle(style) {
70
70
  if (bg && bg.toLowerCase() !== "ffffff")
71
71
  info.highlight = bg;
72
72
  info.size = cssSizeToHalfPoints(getProp(style, "font-size"));
73
+ if (getProp(style, "text-transform") === "uppercase")
74
+ info.caps = true;
73
75
  const family = getProp(style, "font-family");
74
76
  if (family)
75
77
  info.font = family.split(",")[0].trim().replace(/["']/g, "");
@@ -123,6 +125,7 @@ function measureEl(el) {
123
125
  color: cssColorToHex(cs.color),
124
126
  bold: (Number.parseInt(cs.fontWeight) || 400) >= 600,
125
127
  italics: cs.fontStyle.includes("italic"),
128
+ caps: cs.textTransform === "uppercase",
126
129
  align: cssAlignToDocx(cs.textAlign)
127
130
  };
128
131
  }
@@ -145,6 +148,7 @@ function runOptions(info) {
145
148
  italics: info.italics,
146
149
  underline: info.underline ? {} : void 0,
147
150
  strike: info.strike,
151
+ allCaps: info.caps || void 0,
148
152
  color: info.color,
149
153
  size: info.size,
150
154
  font: info.font,
@@ -165,6 +169,22 @@ function primaryFont(cs) {
165
169
  const family = cs.fontFamily?.split(",")[0]?.trim().replace(/["']/g, "");
166
170
  return family || void 0;
167
171
  }
172
+ function withMarks(base, marks) {
173
+ const info = { ...base };
174
+ for (const mark of marks) {
175
+ if (mark.type.name === "bold")
176
+ info.bold = true;
177
+ if (mark.type.name === "italic")
178
+ info.italics = true;
179
+ if (mark.type.name === "underline")
180
+ info.underline = true;
181
+ if (mark.type.name === "strike")
182
+ info.strike = true;
183
+ if (mark.type.name === "inlineStyle")
184
+ Object.assign(info, cleanUndefined(parseStyle(mark.attrs.style)));
185
+ }
186
+ return info;
187
+ }
168
188
  function inlineToRuns(node, base, ctx) {
169
189
  const runs = [];
170
190
  node.forEach((child) => {
@@ -186,7 +206,7 @@ function inlineToRuns(node, base, ctx) {
186
206
  }
187
207
  if (child.type.name === "variableChip") {
188
208
  if (child.attrs.text)
189
- runs.push(new TextRun({ text: child.attrs.text, ...runOptions(base) }));
209
+ runs.push(new TextRun({ text: child.attrs.text, ...runOptions(withMarks(base, child.marks)) }));
190
210
  return;
191
211
  }
192
212
  if (child.type.name === "hardBreak") {
@@ -245,6 +265,7 @@ function domInlineToRuns(root, fallback = {}) {
245
265
  italics: cs.fontStyle.includes("italic") || void 0,
246
266
  underline: cs.textDecorationLine?.includes("underline") ? {} : void 0,
247
267
  strike: cs.textDecorationLine?.includes("line-through") || void 0,
268
+ allCaps: cs.textTransform === "uppercase" || void 0,
248
269
  characterSpacing: TRACKING_COMPENSATION_TW
249
270
  }));
250
271
  }
@@ -296,7 +317,7 @@ function spacingFrom(m, style) {
296
317
  function textBlockToParagraph(node, pos, ctx, opts = {}) {
297
318
  const m = measureAt(ctx, pos);
298
319
  const style = node.attrs.style;
299
- const base = m ? { bold: m.bold, italics: m.italics, color: m.color, size: m.sizeHalf, font: primaryFont(m.cs) } : parseStyle(style);
320
+ const base = m ? { bold: m.bold, italics: m.italics, caps: m.caps, color: m.color, size: m.sizeHalf, font: primaryFont(m.cs) } : parseStyle(style);
300
321
  if (ctx.extraTrackingTw)
301
322
  base.trackingTw = ctx.extraTrackingTw;
302
323
  const ownIndent = m ? m.marginLeftTw + Math.round((Number.parseFloat(m.cs.paddingLeft) || 0) * PX_TO_TWIP) : cssLengthToTwips(getProp(style, "margin-left")) ?? 0;
@@ -652,6 +673,66 @@ function isFlexRow(node) {
652
673
  return false;
653
674
  return node.childCount > 1;
654
675
  }
676
+ function islandImage(node) {
677
+ if (node.type.name !== "htmlIsland")
678
+ return null;
679
+ const tpl = document.createElement("template");
680
+ tpl.innerHTML = node.attrs.html || "";
681
+ const el = tpl.content.firstElementChild;
682
+ if (!el)
683
+ return null;
684
+ const img = el.tagName === "IMG" ? el : el.querySelector("img");
685
+ return img instanceof HTMLImageElement ? img : null;
686
+ }
687
+ function collectImageIslands(node, pos) {
688
+ if (islandImage(node))
689
+ return [{ node, pos }];
690
+ if (node.type.name !== "styledDiv")
691
+ return null;
692
+ const out = [];
693
+ let childPos = pos + 1;
694
+ for (let i = 0; i < node.childCount; i++) {
695
+ const child = node.child(i);
696
+ if (!(child.isTextblock && child.content.size === 0)) {
697
+ const inner = collectImageIslands(child, childPos);
698
+ if (!inner)
699
+ return null;
700
+ out.push(...inner);
701
+ }
702
+ childPos += child.nodeSize;
703
+ }
704
+ return out.length ? out : null;
705
+ }
706
+ async function inlineImagesParagraph(islands, innerPx, ctx) {
707
+ const GAP_PX = 8;
708
+ const items = [];
709
+ for (const { node, pos } of islands) {
710
+ const img = islandImage(node);
711
+ const src = await srcToDataUrl(img.getAttribute("src") || "");
712
+ if (!src)
713
+ continue;
714
+ const live = domAt(ctx, pos)?.querySelector("img");
715
+ const styleW = Number.parseFloat(getProp(img.getAttribute("style"), "width") ?? "");
716
+ const widthPx = live?.offsetWidth || 0 || (Number.isFinite(styleW) && styleW > 0 ? styleW : 0) || Math.min(innerPx, 200);
717
+ items.push({ src, widthPx });
718
+ }
719
+ if (!items.length)
720
+ return null;
721
+ const total = items.reduce((a, b) => a + b.widthPx, 0) + GAP_PX * (items.length - 1);
722
+ const scale = innerPx > 0 && total > innerPx ? (innerPx - GAP_PX * (items.length - 1)) / (total - GAP_PX * (items.length - 1)) : 1;
723
+ const runs = [];
724
+ for (let i = 0; i < items.length; i++) {
725
+ const run = await dataUrlToImageRun(items[i].src, Math.max(1, Math.round(items[i].widthPx * scale)));
726
+ if (!run)
727
+ continue;
728
+ if (runs.length)
729
+ runs.push(new TextRun({ text: " " }));
730
+ runs.push(run);
731
+ }
732
+ if (!runs.length)
733
+ return null;
734
+ return new Paragraph({ children: runs, alignment: AlignmentType.CENTER, spacing: { after: 0, before: 0 } });
735
+ }
655
736
  async function tableToDocx(node, pos, contentWidthPx, ctx) {
656
737
  const tableDomRaw = domAt(ctx, pos);
657
738
  const tableEl = tableDomRaw?.tagName === "TABLE" ? tableDomRaw : tableDomRaw?.querySelector("table") ?? null;
@@ -681,9 +762,36 @@ async function tableToDocx(node, pos, contentWidthPx, ctx) {
681
762
  }
682
763
  }
683
764
  const cellCtx = { ...ctx, insideCell: true, indentLeft: 0, lastAfterTw: 0, pending: { breakBefore: false }, extraTrackingTw: extraTrackingTw || void 0 };
765
+ const cellInnerPx = cellM?.el ? cellM.el.clientWidth - (Number.parseFloat(cellM.cs.paddingLeft) || 0) - (Number.parseFloat(cellM.cs.paddingRight) || 0) : contentWidthPx / 2;
766
+ const isEmptyBlock = (n) => n.isTextblock && n.content.size === 0;
767
+ const besideImage = (k) => k > 0 && !!collectImageIslands(cellNode.child(k - 1), 0) || k + 1 < cellNode.childCount && !!collectImageIslands(cellNode.child(k + 1), 0);
684
768
  let childPos = cellPos + 1;
685
769
  for (let k = 0; k < cellNode.childCount; k++) {
686
770
  const child = cellNode.child(k);
771
+ if (isEmptyBlock(child) && besideImage(k)) {
772
+ childPos += child.nodeSize;
773
+ continue;
774
+ }
775
+ const islands = collectImageIslands(child, childPos);
776
+ if (islands?.length) {
777
+ let end = k + 1;
778
+ let endPos = childPos + child.nodeSize;
779
+ while (end < cellNode.childCount) {
780
+ const next = cellNode.child(end);
781
+ const more = collectImageIslands(next, endPos);
782
+ if (!more?.length)
783
+ break;
784
+ islands.push(...more);
785
+ endPos += next.nodeSize;
786
+ end++;
787
+ }
788
+ const para = await inlineImagesParagraph(islands, cellInnerPx, cellCtx);
789
+ if (para)
790
+ cellContent.push(para);
791
+ childPos = endPos;
792
+ k = end - 1;
793
+ continue;
794
+ }
687
795
  if (child.isTextblock)
688
796
  cellContent.push(textBlockToParagraph(child, childPos, cellCtx));
689
797
  else
package/dist/index.d.mts CHANGED
@@ -232,6 +232,12 @@ interface MinutaEditorOptions {
232
232
  toolbar?: boolean;
233
233
  /** realce de proveniência de bloco no hover (só faz sentido no modo estruturado) */
234
234
  showBlockOrigin?: boolean;
235
+ /**
236
+ * Dispara `onBlockClick` também com o editor editável. Desligado por
237
+ * padrão: na tela do defesa.ai o clique em edição é para posicionar o
238
+ * cursor, e abrir o bloco do checklist a cada clique atrapalharia.
239
+ */
240
+ blockClickWhileEditing?: boolean;
235
241
  onReady?: (tiptap: Editor) => void;
236
242
  /** o documento mudou por edição (troca de imagem fria não conta) */
237
243
  onChange?: () => void;
@@ -274,6 +280,8 @@ interface MinutaEditor {
274
280
  setToolbar: (on: boolean) => void;
275
281
  setMargins: (margins: MinutaMargins) => void;
276
282
  setShowBlockOrigin: (on: boolean) => void;
283
+ /** liga/desliga `onBlockClick` em edição depois de criado (ver `blockClickWhileEditing`) */
284
+ setBlockClickWhileEditing: (on: boolean) => void;
277
285
  /**
278
286
  * Ponte formulário → documento: rola até a primeira ocorrência da variável
279
287
  * e a destaca por um instante. `false` se ela não está no documento.
@@ -286,7 +294,13 @@ interface MinutaEditor {
286
294
  * editou o texto em volta. Devolve quantas ocorrências mudaram.
287
295
  */
288
296
  setVariableValue: (variableId: string, value: string) => number;
289
- /** as variáveis presentes no documento, na ordem, sem repetição */
297
+ /**
298
+ * Troca a imagem de uma variável de imagem: o `<img data-variable-id>` ganha
299
+ * o `src` novo, e um aviso de imagem faltando com esse id vira a imagem.
300
+ * Devolve quantas ocorrências mudaram.
301
+ */
302
+ setVariableImage: (variableId: string, src: string) => number;
303
+ /** ids marcados no documento, na ordem, sem repetição: chips de texto, imagens e imagens faltando */
290
304
  variablesInDocument: () => string[];
291
305
  /** repagina (o host chama após mudar algo que afeta altura fora do editor) */
292
306
  schedulePaginate: () => void;
package/dist/index.d.ts CHANGED
@@ -232,6 +232,12 @@ interface MinutaEditorOptions {
232
232
  toolbar?: boolean;
233
233
  /** realce de proveniência de bloco no hover (só faz sentido no modo estruturado) */
234
234
  showBlockOrigin?: boolean;
235
+ /**
236
+ * Dispara `onBlockClick` também com o editor editável. Desligado por
237
+ * padrão: na tela do defesa.ai o clique em edição é para posicionar o
238
+ * cursor, e abrir o bloco do checklist a cada clique atrapalharia.
239
+ */
240
+ blockClickWhileEditing?: boolean;
235
241
  onReady?: (tiptap: Editor) => void;
236
242
  /** o documento mudou por edição (troca de imagem fria não conta) */
237
243
  onChange?: () => void;
@@ -274,6 +280,8 @@ interface MinutaEditor {
274
280
  setToolbar: (on: boolean) => void;
275
281
  setMargins: (margins: MinutaMargins) => void;
276
282
  setShowBlockOrigin: (on: boolean) => void;
283
+ /** liga/desliga `onBlockClick` em edição depois de criado (ver `blockClickWhileEditing`) */
284
+ setBlockClickWhileEditing: (on: boolean) => void;
277
285
  /**
278
286
  * Ponte formulário → documento: rola até a primeira ocorrência da variável
279
287
  * e a destaca por um instante. `false` se ela não está no documento.
@@ -286,7 +294,13 @@ interface MinutaEditor {
286
294
  * editou o texto em volta. Devolve quantas ocorrências mudaram.
287
295
  */
288
296
  setVariableValue: (variableId: string, value: string) => number;
289
- /** as variáveis presentes no documento, na ordem, sem repetição */
297
+ /**
298
+ * Troca a imagem de uma variável de imagem: o `<img data-variable-id>` ganha
299
+ * o `src` novo, e um aviso de imagem faltando com esse id vira a imagem.
300
+ * Devolve quantas ocorrências mudaram.
301
+ */
302
+ setVariableImage: (variableId: string, src: string) => number;
303
+ /** ids marcados no documento, na ordem, sem repetição: chips de texto, imagens e imagens faltando */
290
304
  variablesInDocument: () => string[];
291
305
  /** repagina (o host chama após mudar algo que afeta altura fora do editor) */
292
306
  schedulePaginate: () => void;
package/dist/index.mjs CHANGED
@@ -1426,8 +1426,9 @@ function createMinutaEditor(element, opts) {
1426
1426
  opts.onEditChrome?.(part);
1427
1427
  window.dispatchEvent(new CustomEvent("poc:edit-chrome", { detail: { part, editor: tiptap } }));
1428
1428
  }
1429
+ let blockClickWhileEditing = opts.blockClickWhileEditing === true;
1429
1430
  function onSheetClick(event) {
1430
- if (editable)
1431
+ if (editable && !blockClickWhileEditing)
1431
1432
  return;
1432
1433
  const target = event.target;
1433
1434
  if (target.closest(".poc-varchip") || target.closest(".poc-page-headerarea") || target.closest(".poc-page-footerarea") || target.closest(".poc-first-header") || target.closest(".poc-last-footer")) {
@@ -1652,9 +1653,29 @@ function createMinutaEditor(element, opts) {
1652
1653
  text: String(detail.text ?? "")
1653
1654
  });
1654
1655
  }
1656
+ function variableTargetElement(variableId) {
1657
+ const id = CSS.escape(variableId);
1658
+ const chip = sheet.querySelector(`.poc-varchip [data-variable-id="${id}"], .poc-varchip[data-variable-id="${id}"]`);
1659
+ if (chip)
1660
+ return chip.closest(".poc-varchip") ?? chip;
1661
+ const img = sheet.querySelector(`img[data-variable-id="${id}"]`);
1662
+ if (img)
1663
+ return img;
1664
+ const ed = tiptap;
1665
+ if (!ed || ed.isDestroyed)
1666
+ return null;
1667
+ let found = null;
1668
+ ed.state.doc.descendants((node, pos) => {
1669
+ if (found || node.type.name !== "missingImage" || node.attrs.variableId !== variableId)
1670
+ return;
1671
+ const dom = ed.view.nodeDOM(pos);
1672
+ if (dom instanceof HTMLElement)
1673
+ found = dom;
1674
+ });
1675
+ return found;
1676
+ }
1655
1677
  function scrollToVariable(variableId) {
1656
- const el = sheet.querySelector(`.poc-varchip [data-variable-id="${CSS.escape(variableId)}"], .poc-varchip[data-variable-id="${CSS.escape(variableId)}"]`);
1657
- const chip = el?.closest(".poc-varchip") ?? el;
1678
+ const chip = variableTargetElement(variableId);
1658
1679
  if (!chip)
1659
1680
  return false;
1660
1681
  chip.scrollIntoView({ block: "center", behavior: "smooth" });
@@ -1682,17 +1703,66 @@ function createMinutaEditor(element, opts) {
1682
1703
  ed.view.dispatch(tr);
1683
1704
  return count;
1684
1705
  }
1706
+ function setVariableImage(variableId, src) {
1707
+ const ed = tiptap;
1708
+ if (!ed || ed.isDestroyed)
1709
+ return 0;
1710
+ const islandType = ed.state.schema.nodes.htmlIsland;
1711
+ const tr = ed.state.tr;
1712
+ let count = 0;
1713
+ const hits = [];
1714
+ ed.state.doc.descendants((node, pos) => {
1715
+ if (node.type.name === "missingImage" && node.attrs.variableId === variableId)
1716
+ hits.push({ pos, node });
1717
+ else if (node.type.name === "htmlIsland" && String(node.attrs.html ?? "").includes(`data-variable-id="${variableId}"`))
1718
+ hits.push({ pos, node });
1719
+ });
1720
+ for (const { pos, node } of hits.reverse()) {
1721
+ if (node.type.name === "missingImage") {
1722
+ if (!islandType)
1723
+ continue;
1724
+ const html = `<img src="${escapeAttr(src)}" alt="${escapeAttr(variableId)}" data-variable-id="${escapeAttr(variableId)}" data-parent-block-id="${escapeAttr(String(node.attrs.parentBlockId ?? ""))}" style="max-width: 100%; height: auto;" />`;
1725
+ tr.replaceWith(pos, pos + node.nodeSize, islandType.create({ html }));
1726
+ count++;
1727
+ continue;
1728
+ }
1729
+ const tpl = document.createElement("template");
1730
+ tpl.innerHTML = String(node.attrs.html ?? "");
1731
+ const imgs = tpl.content.querySelectorAll(`img[data-variable-id="${CSS.escape(variableId)}"]`);
1732
+ if (!imgs.length)
1733
+ continue;
1734
+ for (const img of imgs)
1735
+ img.setAttribute("src", src);
1736
+ tr.setNodeMarkup(pos, void 0, { ...node.attrs, html: tpl.innerHTML });
1737
+ count += imgs.length;
1738
+ }
1739
+ if (count)
1740
+ ed.view.dispatch(tr);
1741
+ return count;
1742
+ }
1685
1743
  function variablesInDocument() {
1686
1744
  const ed = tiptap;
1687
1745
  const ids = [];
1688
1746
  if (!ed || ed.isDestroyed)
1689
1747
  return ids;
1748
+ const add = (id) => {
1749
+ if (typeof id === "string" && id && !ids.includes(id))
1750
+ ids.push(id);
1751
+ };
1690
1752
  ed.state.doc.descendants((node) => {
1691
- if (node.type.name === "variableChip" && node.attrs.variableId && !ids.includes(node.attrs.variableId))
1692
- ids.push(node.attrs.variableId);
1753
+ if (node.type.name === "variableChip" || node.type.name === "missingImage") {
1754
+ add(node.attrs.variableId);
1755
+ } else if (node.type.name === "htmlIsland") {
1756
+ for (const m of String(node.attrs.html ?? "").matchAll(/data-variable-id="([^"]+)"/g)) {
1757
+ add(m[1]);
1758
+ }
1759
+ }
1693
1760
  });
1694
1761
  return ids;
1695
1762
  }
1763
+ function escapeAttr(value) {
1764
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1765
+ }
1696
1766
  function buildBubbleMenu() {
1697
1767
  const TEXT_COLORS = [
1698
1768
  { label: "Preto", value: "#1C242D" },
@@ -1862,8 +1932,12 @@ function createMinutaEditor(element, opts) {
1862
1932
  setShowBlockOrigin(on) {
1863
1933
  toggleClass(root, "poc-no-block-origin", !on);
1864
1934
  },
1935
+ setBlockClickWhileEditing(on) {
1936
+ blockClickWhileEditing = on;
1937
+ },
1865
1938
  scrollToVariable,
1866
1939
  setVariableValue,
1940
+ setVariableImage,
1867
1941
  variablesInDocument,
1868
1942
  schedulePaginate,
1869
1943
  refreshChrome,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/minuta-editor",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Editor de minuta do defesa.ai sem framework: folha A4 paginada, edição rica, placeholders de variável e export DOCX fiel ao preview — monta em qualquer app (React, Vue, vanilla)",
5
5
  "repository": {
6
6
  "type": "git",