@meistrari/minuta-react 1.0.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/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # @meistrari/minuta-react
2
+
3
+ O editor de minuta do **defesa.ai** como componente React — a folha A4 paginada,
4
+ edição rica, placeholders de variável e export DOCX fiel ao preview. Casca fina
5
+ sobre [`@meistrari/minuta-editor`](../minuta-editor), o núcleo sem framework.
6
+
7
+ ```tsx
8
+ import { useRef } from 'react'
9
+ import { MinutaEditor, type MinutaEditorHandle } from '@meistrari/minuta-react'
10
+ import '@meistrari/minuta-editor/style.css'
11
+
12
+ function Lapidacao({ html, onSave }: { html: string, onSave: (html: string) => void }) {
13
+ const editor = useRef<MinutaEditorHandle>(null)
14
+ return (
15
+ <>
16
+ <button onClick={() => onSave(editor.current!.getHTML())}>Salvar</button>
17
+ <MinutaEditor ref={editor} html={html} style={{ height: '80vh' }} />
18
+ </>
19
+ )
20
+ }
21
+ ```
22
+
23
+ **Zero I/O.** O componente não busca nem salva nada — recebe HTML, devolve HTML e
24
+ um `Blob` de `.docx`. Buscar o documento, persistir a edição e baixar o arquivo
25
+ é do seu app (abaixo, o fluxo com a Data API do defesa.ai).
26
+
27
+ ## Props
28
+
29
+ | prop | default | |
30
+ |---|---|---|
31
+ | `html` | — | documento inicial. **Lido uma vez** — para carregar outro, troque a `key` |
32
+ | `editable` | `true` | |
33
+ | `margins` | 2,5cm / 3cm | `{ top, right, bottom, left }` em px @96dpi |
34
+ | `header` | — | `{ logoUrl, position }` |
35
+ | `chrome` | — | `{ header, footer, headerScope, footerScope, showPageNumber }` |
36
+ | `variables` | — | catálogo `{ key, label, description?, group? }[]` → liga o modo template |
37
+ | `uploadImage` | — | `(file) => Promise<{ src, vaultSrc } \| null>` |
38
+ | `toolbar` | `true` | `false` = você monta a sua sobre `ref.current.tiptap` |
39
+ | `onChange` | — | `(html) => void`, a cada edição |
40
+ | `onTransaction` | — | qualquer transação, inclusive só de seleção — para toolbar própria |
41
+ | `onReady`, `onEditChrome`, `onBlockClick`, `onError` | — | |
42
+
43
+ O `ref` entrega a instância do editor: `getHTML()`, `exportDocx()` (→ `Blob`),
44
+ `usedVariables()`, `setEditable()`, `setChrome()`, `tiptap` (a instância do
45
+ Tiptap — escotilha de fuga), `root`/`container`/`sheet` (os elementos).
46
+
47
+ ### Por que o `html` é lido uma vez
48
+
49
+ O editor é a fonte da verdade enquanto vive. Realimentá-lo com o próprio
50
+ `getHTML()` a cada tecla remontaria o documento e mandaria o cursor para o
51
+ começo. Guarde o que `onChange` entrega; para abrir outro documento, mude a
52
+ `key`:
53
+
54
+ ```tsx
55
+ <MinutaEditor key={edicao.id} html={edicao.html} onChange={setHtml} />
56
+ ```
57
+
58
+ ### Toolbar própria (antd, Material…)
59
+
60
+ `toolbar={false}` e fale com o Tiptap direto. `onTransaction` é o que faz
61
+ `isActive` acompanhar o cursor, não só o texto:
62
+
63
+ ```tsx
64
+ const [, rerender] = useReducer(x => x + 1, 0)
65
+ const t = editor.current?.tiptap
66
+
67
+ <Button type={t?.isActive('bold') ? 'primary' : 'default'} onClick={() => t?.chain().focus().toggleBold().run()}>B</Button>
68
+ <MinutaEditor ref={editor} html={html} toolbar={false} onTransaction={rerender} />
69
+ ```
70
+
71
+ Os tipos de `chain().toggleBold()` viajam com o pacote — não precisa importar
72
+ `@tiptap/*` no seu código.
73
+
74
+ ## O fluxo com a Data API do defesa.ai
75
+
76
+ Tudo pelo seu backend, com `x-api-key` + `x-org-id` — seus usuários não precisam
77
+ de conta no defesa.ai:
78
+
79
+ ```
80
+ GET /data/case/:id/html → { html, chrome, margins, source, editionId }
81
+ POST /data/case/:id/editions { name, html } → cria a edição (o documento lapidado)
82
+ PATCH /data/case/:id/editions/:editionId { html } → salva (versão automática)
83
+ POST /data/case/:id/docx → .docx do estado atual (a edição, se existir)
84
+ ```
85
+
86
+ ```tsx
87
+ // abrir
88
+ const doc = await api.get(`/case/${caseId}/html`)
89
+ <MinutaEditor key={caseId} html={doc.html} chrome={doc.chrome ?? undefined} margins={doc.margins ?? undefined} onChange={setHtml} />
90
+
91
+ // salvar (debounce do seu lado)
92
+ await api.patch(`/case/${caseId}/editions/${editionId}`, { html })
93
+
94
+ // baixar — fiel ao preview, gerado no browser
95
+ const blob = await editor.current!.exportDocx()
96
+ ```
97
+
98
+ Mande `x-user-email` / `x-user-name` nas chamadas de edição para a autoria
99
+ ficar registrada.
100
+
101
+ ## Formulário ↔ documento
102
+
103
+ O HTML do `GET /data/case/:id/html` vem com cada variável marcada —
104
+ `<span data-variable-id="nome_cliente" data-parent-block-id="…">valor</span>` —
105
+ e o editor trata a marca como nó atômico. Isso dá as duas direções sem casar
106
+ texto (que erra quando o valor coincide com algo que já existia no template):
107
+
108
+ ```tsx
109
+ // documento → formulário: clicou no trecho, foca o campo
110
+ <MinutaEditor ref={editor} html={html} onVariableClick={v => focusField(v.variableId)} />
111
+
112
+ // formulário → documento: focou o campo, rola até o trecho
113
+ editor.current!.scrollToVariable('nome_cliente')
114
+
115
+ // e o formulário continua valendo DEPOIS da edição livre: a marca sobrevive
116
+ // à lapidação, então mudar o valor troca o trecho sem tocar no texto em volta
117
+ editor.current!.setVariableValue('nome_cliente', 'Maria da Silva') // → nº de ocorrências
118
+ editor.current!.variablesInDocument() // → ['nome_cliente', …]
119
+ ```
120
+
121
+ A resposta do endpoint traz `variablesMarked` e `markedVariables`: numa
122
+ edição criada a partir de HTML já resolvido (sem marcas) a ponte não existe, e
123
+ é melhor saber antes de montar a tela do que descobrir com
124
+ `variablesInDocument()` vazio.
125
+
126
+ Sobre o fluxo de "enviar variáveis de volta": com uma edição viva o documento
127
+ **é** a edição — o `PATCH` de variáveis no case não re-renderiza a peça (senão
128
+ apagaria o texto do advogado). Ou o formulário trava quando a lapidação começa
129
+ (o modelo do defesa.ai), ou você mantém os dois vivos com `setVariableValue` +
130
+ salva a edição. Decisão de UX do seu app; as duas funcionam.
131
+
132
+ ## Next.js (App Router)
133
+
134
+ O entry publicado carrega `'use client'`: dá para importar o componente direto
135
+ de um Server Component, que o Next faz a fronteira de cliente sozinho. O editor
136
+ é DOM, então `dynamic(() => import(...), { ssr: false })` continua sendo uma
137
+ boa ideia em página renderizada no servidor.
138
+
139
+ ## Props depois do mount
140
+
141
+ Todas as props são sincronizadas com o editor enquanto ele vive — `toolbar`,
142
+ `margins`, `chrome`, `header`, `variables`, `editable`, `showBlockOrigin`. A
143
+ única só de mount é `html`; voltar uma prop a `undefined` volta ao default.
144
+
145
+ ## StrictMode
146
+
147
+ Funciona com o `<React.StrictMode>` do React 18: o duplo-mount do efeito destrói
148
+ e recria a instância sobre o mesmo nó, sem instância órfã nem listener duplicado.
149
+ O playground roda assim de propósito.
150
+
151
+ ## Desenvolvimento
152
+
153
+ ```bash
154
+ bun install && bun link @meistrari/minuta-editor # o core, via link em dev
155
+ bun run dev # playground em :3299 (caso real como fixture)
156
+ bun run build
157
+ ```
@@ -0,0 +1,56 @@
1
+ /// <reference types="@tiptap/extension-bold" />
2
+ /// <reference types="@tiptap/extension-blockquote" />
3
+ /// <reference types="@tiptap/extension-bullet-list" />
4
+ /// <reference types="@tiptap/extension-code" />
5
+ /// <reference types="@tiptap/extension-code-block" />
6
+ /// <reference types="@tiptap/extension-hard-break" />
7
+ /// <reference types="@tiptap/extension-heading" />
8
+ /// <reference types="@tiptap/extension-history" />
9
+ /// <reference types="@tiptap/extension-horizontal-rule" />
10
+ /// <reference types="@tiptap/extension-italic" />
11
+ /// <reference types="@tiptap/extension-list-item" />
12
+ /// <reference types="@tiptap/extension-ordered-list" />
13
+ /// <reference types="@tiptap/extension-paragraph" />
14
+ /// <reference types="@tiptap/extension-strike" />
15
+ /// <reference types="@tiptap/extension-text" />
16
+ /// <reference types="@tiptap/extension-table" />
17
+ /// <reference types="@tiptap/extension-bubble-menu" />
18
+ import * as react from 'react';
19
+ import { CSSProperties } from 'react';
20
+ import { MinutaMargins, MinutaHeader, MinutaChromeText, MinutaVariableCatalog, MinutaEditorOptions, MinutaVariableRef, MinutaEditor as MinutaEditor$1 } from '@meistrari/minuta-editor';
21
+ export { MinutaChromeText, MinutaEditor as MinutaEditorInstance, MinutaEditorOptions, MinutaHeader, MinutaMargins, MinutaVariableCatalog, MinutaVariableDef, MinutaVariableRef, extractTemplateVariables, resolveTemplateVariables } from '@meistrari/minuta-editor';
22
+
23
+ interface MinutaEditorProps {
24
+ /**
25
+ * Documento inicial (HTML). A ÚNICA prop só de mount: lida uma vez — troque a
26
+ * `key` para carregar outro. As demais são sincronizadas com o editor.
27
+ */
28
+ html: string;
29
+ editable?: boolean;
30
+ margins?: MinutaMargins;
31
+ header?: MinutaHeader;
32
+ chrome?: Partial<MinutaChromeText>;
33
+ variables?: MinutaVariableCatalog;
34
+ uploadImage?: MinutaEditorOptions['uploadImage'];
35
+ /** barra de formatação do pacote (default: true). `false` = monte a sua sobre `ref.tiptap` */
36
+ toolbar?: boolean;
37
+ showBlockOrigin?: boolean;
38
+ onReady?: MinutaEditorOptions['onReady'];
39
+ /** o documento mudou por edição — recebe o HTML atual */
40
+ onChange?: (html: string) => void;
41
+ /** qualquer transação, inclusive só de seleção (para toolbar própria) */
42
+ onTransaction?: () => void;
43
+ onEditChrome?: MinutaEditorOptions['onEditChrome'];
44
+ /** clique numa variável do case no documento — a ponte documento → formulário */
45
+ onVariableClick?: (variable: MinutaVariableRef) => void;
46
+ onBlockClick?: MinutaEditorOptions['onBlockClick'];
47
+ onError?: MinutaEditorOptions['onError'];
48
+ className?: string;
49
+ style?: CSSProperties;
50
+ }
51
+ /** o que o `ref` entrega: a instância do editor (ou `null` antes do mount) */
52
+ type MinutaEditorHandle = MinutaEditor$1;
53
+ declare const MinutaEditor: react.ForwardRefExoticComponent<MinutaEditorProps & react.RefAttributes<MinutaEditor$1 | null>>;
54
+
55
+ export { MinutaEditor };
56
+ export type { MinutaEditorHandle, MinutaEditorProps };
@@ -0,0 +1,56 @@
1
+ /// <reference types="@tiptap/extension-bold" />
2
+ /// <reference types="@tiptap/extension-blockquote" />
3
+ /// <reference types="@tiptap/extension-bullet-list" />
4
+ /// <reference types="@tiptap/extension-code" />
5
+ /// <reference types="@tiptap/extension-code-block" />
6
+ /// <reference types="@tiptap/extension-hard-break" />
7
+ /// <reference types="@tiptap/extension-heading" />
8
+ /// <reference types="@tiptap/extension-history" />
9
+ /// <reference types="@tiptap/extension-horizontal-rule" />
10
+ /// <reference types="@tiptap/extension-italic" />
11
+ /// <reference types="@tiptap/extension-list-item" />
12
+ /// <reference types="@tiptap/extension-ordered-list" />
13
+ /// <reference types="@tiptap/extension-paragraph" />
14
+ /// <reference types="@tiptap/extension-strike" />
15
+ /// <reference types="@tiptap/extension-text" />
16
+ /// <reference types="@tiptap/extension-table" />
17
+ /// <reference types="@tiptap/extension-bubble-menu" />
18
+ import * as react from 'react';
19
+ import { CSSProperties } from 'react';
20
+ import { MinutaMargins, MinutaHeader, MinutaChromeText, MinutaVariableCatalog, MinutaEditorOptions, MinutaVariableRef, MinutaEditor as MinutaEditor$1 } from '@meistrari/minuta-editor';
21
+ export { MinutaChromeText, MinutaEditor as MinutaEditorInstance, MinutaEditorOptions, MinutaHeader, MinutaMargins, MinutaVariableCatalog, MinutaVariableDef, MinutaVariableRef, extractTemplateVariables, resolveTemplateVariables } from '@meistrari/minuta-editor';
22
+
23
+ interface MinutaEditorProps {
24
+ /**
25
+ * Documento inicial (HTML). A ÚNICA prop só de mount: lida uma vez — troque a
26
+ * `key` para carregar outro. As demais são sincronizadas com o editor.
27
+ */
28
+ html: string;
29
+ editable?: boolean;
30
+ margins?: MinutaMargins;
31
+ header?: MinutaHeader;
32
+ chrome?: Partial<MinutaChromeText>;
33
+ variables?: MinutaVariableCatalog;
34
+ uploadImage?: MinutaEditorOptions['uploadImage'];
35
+ /** barra de formatação do pacote (default: true). `false` = monte a sua sobre `ref.tiptap` */
36
+ toolbar?: boolean;
37
+ showBlockOrigin?: boolean;
38
+ onReady?: MinutaEditorOptions['onReady'];
39
+ /** o documento mudou por edição — recebe o HTML atual */
40
+ onChange?: (html: string) => void;
41
+ /** qualquer transação, inclusive só de seleção (para toolbar própria) */
42
+ onTransaction?: () => void;
43
+ onEditChrome?: MinutaEditorOptions['onEditChrome'];
44
+ /** clique numa variável do case no documento — a ponte documento → formulário */
45
+ onVariableClick?: (variable: MinutaVariableRef) => void;
46
+ onBlockClick?: MinutaEditorOptions['onBlockClick'];
47
+ onError?: MinutaEditorOptions['onError'];
48
+ className?: string;
49
+ style?: CSSProperties;
50
+ }
51
+ /** o que o `ref` entrega: a instância do editor (ou `null` antes do mount) */
52
+ type MinutaEditorHandle = MinutaEditor$1;
53
+ declare const MinutaEditor: react.ForwardRefExoticComponent<MinutaEditorProps & react.RefAttributes<MinutaEditor$1 | null>>;
54
+
55
+ export { MinutaEditor };
56
+ export type { MinutaEditorHandle, MinutaEditorProps };
package/dist/index.mjs ADDED
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+ import { forwardRef, useRef, useLayoutEffect, useEffect, useImperativeHandle, createElement } from 'react';
3
+ import { createMinutaEditor } from '@meistrari/minuta-editor';
4
+ export { extractTemplateVariables, resolveTemplateVariables } from '@meistrari/minuta-editor';
5
+
6
+ const MinutaEditor = forwardRef((props, ref) => {
7
+ const host = useRef(null);
8
+ const instance = useRef(null);
9
+ const callbacks = useRef(props);
10
+ callbacks.current = props;
11
+ useLayoutEffect(() => {
12
+ const el = host.current;
13
+ if (!el)
14
+ return;
15
+ const editor = createMinutaEditor(el, {
16
+ html: props.html,
17
+ editable: props.editable,
18
+ margins: props.margins,
19
+ header: props.header,
20
+ chrome: props.chrome,
21
+ variables: props.variables,
22
+ // lido na hora do upload, não no mount: o host pode trocar a função
23
+ uploadImage: (file) => callbacks.current.uploadImage ? callbacks.current.uploadImage(file) : Promise.resolve(null),
24
+ toolbar: props.toolbar,
25
+ showBlockOrigin: props.showBlockOrigin,
26
+ onReady: (tiptap) => callbacks.current.onReady?.(tiptap),
27
+ onChange: () => callbacks.current.onChange?.(editor.getHTML()),
28
+ onTransaction: () => callbacks.current.onTransaction?.(),
29
+ onEditChrome: (part) => callbacks.current.onEditChrome?.(part),
30
+ onVariableClick: (v) => callbacks.current.onVariableClick?.(v),
31
+ onBlockClick: (id) => callbacks.current.onBlockClick?.(id),
32
+ onError: (message) => callbacks.current.onError?.(message)
33
+ });
34
+ instance.current = editor;
35
+ return () => {
36
+ editor.destroy();
37
+ instance.current = null;
38
+ };
39
+ }, []);
40
+ useEffect(() => {
41
+ if (props.editable !== void 0)
42
+ instance.current?.setEditable(props.editable);
43
+ }, [props.editable]);
44
+ useEffect(() => {
45
+ instance.current?.setChrome(props.chrome ?? { header: "", footer: "", headerScope: "all", footerScope: "all", showPageNumber: true });
46
+ }, [props.chrome?.header, props.chrome?.footer, props.chrome?.headerScope, props.chrome?.footerScope, props.chrome?.showPageNumber]);
47
+ useEffect(() => {
48
+ instance.current?.setHeader(props.header ?? { logoUrl: null, position: "left" });
49
+ }, [props.header?.logoUrl, props.header?.position]);
50
+ useEffect(() => {
51
+ instance.current?.setVariables(props.variables);
52
+ }, [props.variables]);
53
+ useEffect(() => {
54
+ instance.current?.setToolbar(props.toolbar !== false);
55
+ }, [props.toolbar]);
56
+ useEffect(() => {
57
+ if (props.margins)
58
+ instance.current?.setMargins(props.margins);
59
+ }, [props.margins?.top, props.margins?.right, props.margins?.bottom, props.margins?.left]);
60
+ useEffect(() => {
61
+ instance.current?.setShowBlockOrigin(props.showBlockOrigin !== false);
62
+ }, [props.showBlockOrigin]);
63
+ useImperativeHandle(ref, () => instance.current);
64
+ return createElement("div", {
65
+ ref: host,
66
+ className: props.className,
67
+ style: { height: "100%", width: "100%", minHeight: 0, display: "flex", flexDirection: "column", ...props.style }
68
+ });
69
+ });
70
+ MinutaEditor.displayName = "MinutaEditor";
71
+
72
+ export { MinutaEditor };
package/package.json ADDED
@@ -0,0 +1,98 @@
1
+ {
2
+ "name": "@meistrari/minuta-react",
3
+ "version": "1.0.0",
4
+ "description": "Componente React do editor de minuta do defesa.ai — casca fina sobre @meistrari/minuta-editor",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/meistrari/case-defense-builder.git",
8
+ "directory": "packages/minuta-react"
9
+ },
10
+ "license": "UNLICENSED",
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.mts",
15
+ "import": "./dist/index.mjs"
16
+ }
17
+ },
18
+ "main": "./dist/index.mjs",
19
+ "types": "./dist/index.d.mts",
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "scripts": {
24
+ "prepack": "bun run build",
25
+ "build": "unbuild && node scripts/finish-dist.mjs",
26
+ "dev": "vite playground --port 3299",
27
+ "typecheck": "tsc --noEmit",
28
+ "test:pack": "node scripts/verify-pack.mjs"
29
+ },
30
+ "dependencies": {
31
+ "@meistrari/minuta-editor": "^1.0.0",
32
+ "@tiptap/core": "2.11.9",
33
+ "@tiptap/extension-blockquote": "2.11.9",
34
+ "@tiptap/extension-bold": "2.11.9",
35
+ "@tiptap/extension-bubble-menu": "2.11.9",
36
+ "@tiptap/extension-bullet-list": "2.11.9",
37
+ "@tiptap/extension-code": "2.11.9",
38
+ "@tiptap/extension-code-block": "2.11.9",
39
+ "@tiptap/extension-dropcursor": "2.11.9",
40
+ "@tiptap/extension-gapcursor": "2.11.9",
41
+ "@tiptap/extension-hard-break": "2.11.9",
42
+ "@tiptap/extension-heading": "2.11.9",
43
+ "@tiptap/extension-history": "2.11.9",
44
+ "@tiptap/extension-horizontal-rule": "2.11.9",
45
+ "@tiptap/extension-italic": "2.11.9",
46
+ "@tiptap/extension-list-item": "2.11.9",
47
+ "@tiptap/extension-ordered-list": "2.11.9",
48
+ "@tiptap/extension-paragraph": "2.11.9",
49
+ "@tiptap/extension-strike": "2.11.9",
50
+ "@tiptap/extension-table": "2.11.9",
51
+ "@tiptap/extension-table-cell": "2.11.9",
52
+ "@tiptap/extension-table-header": "2.11.9",
53
+ "@tiptap/extension-table-row": "2.11.9",
54
+ "@tiptap/extension-text": "2.11.9",
55
+ "@tiptap/pm": "2.11.9"
56
+ },
57
+ "peerDependencies": {
58
+ "react": ">=18.0.0"
59
+ },
60
+ "devDependencies": {
61
+ "@types/react": "18.3.31",
62
+ "@types/react-dom": "18.3.7",
63
+ "@vitejs/plugin-react": "4.7.0",
64
+ "react": "18.3.1",
65
+ "react-dom": "18.3.1",
66
+ "typescript": "5.9.3",
67
+ "unbuild": "2.0.0",
68
+ "vite": "6.4.3"
69
+ },
70
+ "publishConfig": {
71
+ "access": "public"
72
+ },
73
+ "overrides": {
74
+ "@tiptap/core": "2.11.9",
75
+ "@tiptap/extension-blockquote": "2.11.9",
76
+ "@tiptap/extension-bold": "2.11.9",
77
+ "@tiptap/extension-bubble-menu": "2.11.9",
78
+ "@tiptap/extension-bullet-list": "2.11.9",
79
+ "@tiptap/extension-code": "2.11.9",
80
+ "@tiptap/extension-code-block": "2.11.9",
81
+ "@tiptap/extension-document": "2.11.9",
82
+ "@tiptap/extension-dropcursor": "2.11.9",
83
+ "@tiptap/extension-floating-menu": "2.11.9",
84
+ "@tiptap/extension-gapcursor": "2.11.9",
85
+ "@tiptap/extension-hard-break": "2.11.9",
86
+ "@tiptap/extension-heading": "2.11.9",
87
+ "@tiptap/extension-history": "2.11.9",
88
+ "@tiptap/extension-horizontal-rule": "2.11.9",
89
+ "@tiptap/extension-italic": "2.11.9",
90
+ "@tiptap/extension-list-item": "2.11.9",
91
+ "@tiptap/extension-ordered-list": "2.11.9",
92
+ "@tiptap/extension-paragraph": "2.11.9",
93
+ "@tiptap/extension-strike": "2.11.9",
94
+ "@tiptap/extension-text": "2.11.9",
95
+ "@tiptap/extension-text-style": "2.11.9",
96
+ "@tiptap/pm": "2.11.9"
97
+ }
98
+ }