@aster-editor/angular 0.0.0-stage → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joris Tremoulet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,396 @@
1
- # Temporary Holding Version
1
+ # ✳️ @aster-editor/angular
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Composant Angular standalone pour [Aster Editor](../../README.md) : `<aster-editor>` avec
4
+ > reactive forms, signals, zoneless, nom accessible, et import / export Word à la demande.
5
+
6
+ ## 📦 Installation
7
+
8
+ ```bash
9
+ npm install @aster-editor/angular @aster-editor/ui @aster-editor/core
10
+ # optionnel, pour le collage Word nettoyé et l'import .docx
11
+ npm install @aster-editor/word
12
+ ```
13
+
14
+ Peer deps : `@angular/core`, `@angular/common`, `@angular/forms` ≥ 20. Angular 22 est la cible,
15
+ l'app peut être zoneless.
16
+
17
+ Importe les thèmes CSS une fois, par exemple dans `styles.css` :
18
+
19
+ ```css
20
+ /* structure + thèmes light et night */
21
+ @import '@aster-editor/ui/themes/aster.css';
22
+
23
+ /* ou, pour alléger : structure + un seul thème */
24
+ @import '@aster-editor/ui/themes/base.css';
25
+ @import '@aster-editor/ui/themes/light.css';
26
+ ```
27
+
28
+ ## 🚀 Exemple minimal
29
+
30
+ ```ts
31
+ import { Component } from '@angular/core';
32
+ import { AsterEditorComponent } from '@aster-editor/angular';
33
+
34
+ @Component({
35
+ selector: 'app-note',
36
+ imports: [AsterEditorComponent],
37
+ template: `<aster-editor
38
+ toolbar="standard"
39
+ placeholder="Écris ici…"
40
+ (changed)="html = $event"
41
+ />`,
42
+ })
43
+ export class NoteComponent {
44
+ html = '';
45
+ }
46
+ ```
47
+
48
+ L'éditeur est monté dans `afterNextRender` (aucun accès DOM côté serveur) et détruit avec le
49
+ composant.
50
+
51
+ ## 📝 Reactive form
52
+
53
+ Le composant implémente `ControlValueAccessor` : la valeur du contrôle est le HTML sémantique de
54
+ `getHTML()`. `dirty` passe à vrai à la frappe, `touched` au blur vers l'extérieur de l'éditeur
55
+ (cliquer la toolbar, un menu ou un dialogue ne compte pas), `disabled` met l'éditeur en lecture
56
+ seule **sans émettre `change`** (`setDisabledState` ne salit jamais le contrôle). `writeValue`
57
+ est idempotent : il n'émet rien, n'entre pas dans l'historique d'annulation, vide l'historique
58
+ antérieur (un `Ctrl+Z` qui suit ne rejoue pas l'ancien document) et ignore une valeur identique
59
+ au contenu courant (la sélection ne bouge pas). Une valeur reçue avant le montage devient le
60
+ contenu initial.
61
+
62
+ ```ts
63
+ import { Component } from '@angular/core';
64
+ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
65
+ import { AsterEditorComponent } from '@aster-editor/angular';
66
+
67
+ @Component({
68
+ selector: 'app-article-form',
69
+ imports: [AsterEditorComponent, ReactiveFormsModule],
70
+ template: `
71
+ <form [formGroup]="form" (ngSubmit)="save()">
72
+ <input formControlName="title" />
73
+ <aster-editor formControlName="body" toolbar="full" />
74
+ <button type="submit" [disabled]="form.invalid">Enregistrer</button>
75
+ </form>
76
+ `,
77
+ })
78
+ export class ArticleFormComponent {
79
+ readonly form = new FormGroup({
80
+ title: new FormControl('', { nonNullable: true }),
81
+ body: new FormControl('', { nonNullable: true }),
82
+ });
83
+
84
+ save(): void {
85
+ console.log(this.form.getRawValue()); // { title: '…', body: '<p>…</p>' }
86
+ }
87
+ }
88
+ ```
89
+
90
+ > Un document vide vaut `<p></p>`, pas `''` : pour un champ requis, écris un validateur qui
91
+ > retire les balises avant de tester la vacuité (voir `apps/demo/src/app/pages/required-rich-text.ts`).
92
+
93
+ ## 🎛️ Inputs et outputs
94
+
95
+ | Input | Type | Défaut effectif | À chaud |
96
+ | ----------------------- | -------------------------- | --------------- | ----------------------------------------------------------- |
97
+ | `toolbar` | `ToolbarConfig` | `'standard'` | Remonte l'éditeur : contenu conservé, historique perdu |
98
+ | `theme` | `AsterTheme` | `'light'` | Oui |
99
+ | `locale` | `AsterLocale` | `'fr'` | Oui, toolbar comprise |
100
+ | `placeholder` | `string \| undefined` | chaîne i18n | Oui |
101
+ | `readonly` | `boolean` | `false` | Oui, cumulé avec le `disabled` du formulaire |
102
+ | `config` | `Partial<AsterUiOptions>` | `{}` | Seules les clés ci-dessus sont relues ; le reste au montage |
103
+ | `wordPaste` | `boolean` | `true` | Oui |
104
+ | `ariaLabel` | `string \| undefined` | aucun | Oui : `aria-label` sur le contenteditable |
105
+ | `ariaLabelledBy` | `string \| undefined` | aucun | Oui : `aria-labelledby` sur le contenteditable |
106
+ | `variables` | `readonly AsterVariable[]` | aucune | Oui : `editor.variables.define(...)`, fusion par nom |
107
+ | `variableValues` | `VariableValues` | aucune | Oui : `editor.variables.setValues(...)`, fusion par nom |
108
+ | `variableResolver` | `VariableResolver` | aucun | Non, lu au montage |
109
+ | `allowUnknownVariables` | `boolean` | `true` | Non, lu au montage |
110
+ | `variableSuggestion` | `boolean` | `true` | Non, lu au montage |
111
+
112
+ Un contenteditable ne s'associe pas à un `<label for>` : nomme la zone de saisie avec
113
+ `ariaLabel`, ou `ariaLabelledBy` pointant sur l'`id` de ton libellé visible. Les deux sont
114
+ appliqués sur `editor.contentElement` (le `.ProseMirror`), retirés quand la valeur est vide,
115
+ et survivent à un remontage.
116
+
117
+ Le remontage déclenché par `toolbar` recrée l'instance `createAster` : le HTML courant est
118
+ réinjecté comme contenu initial, `autofocus` est neutralisé et le focus est restauré en fin de
119
+ document s'il était dans l'éditeur, `ready` est réémis avec la nouvelle instance. Un
120
+ `importDocx` en cours pendant un remontage ou une destruction rend son résultat sans écrire
121
+ dans l'éditeur.
122
+
123
+ | Output | Payload | Quand |
124
+ | ------------------ | ---------------- | ---------------------------------------------------------------------------------------------- |
125
+ | `ready` | `AsterEditor` | Éditeur monté. Réémis après un remontage (`toolbar`) |
126
+ | `changed` | `string` | Document modifié, HTML de `getHTML()`. Pas sur `writeValue` |
127
+ | `focused` | `void` | Focus du contenu |
128
+ | `blurred` | `void` | Focus perdu vers l'extérieur de l'éditeur (pas vers la toolbar) ; marque le contrôle `touched` |
129
+ | `variablesChanged` | `VariableValues` | Valeurs ou déclarations de variables changées (`setValues`, `define`, `resolve`, import) |
130
+
131
+ API publique du composant :
132
+
133
+ - `editor` : `Signal<AsterEditor | null>`, l'éditeur du core (`getHTML`, `getJSON`, `getCounts`,
134
+ `commands.run(...)`, `tiptap`…), `null` avant le montage et après la destruction.
135
+ - `variableNames` : `Signal<readonly string[]>`, noms des variables présentes dans le document,
136
+ relus sur `change` et `variablesChange`.
137
+ - `getHTML(options?)` : HTML du document, `{ variables: 'resolve' }` substitue les valeurs.
138
+ - `resolveVariables()` : appelle le résolveur pour les variables sans valeur, rend les valeurs.
139
+ - `importDocx(file: File): Promise<DocxImportResult>` : voir plus bas.
140
+ - `exportDocx(options?: DocxExportOptions): Promise<Blob>` : voir « Variables et modèles ».
141
+
142
+ La vue source, le plein écran et le menu contextuel de table de `@aster-editor/ui` sont montés
143
+ par `createAster` et donc disponibles dans le composant : via les widgets `source` /
144
+ `fullscreen` d'une toolbar déclarative ou du preset `full`, ou au clavier (`Mod-f` ouvre le
145
+ panneau de recherche si le widget `search` est monté, `Échap` quitte le plein écran). Le composant n'expose pas `AsterInstance` : pour
146
+ piloter ces fonctions par code, passe par les contrôleurs partagés de `ui`
147
+ (`getSourceView(editor)`, `getFullscreen(editor)`) avec l'éditeur du signal.
148
+
149
+ Types réexportés pour ne pas dépendre de `core`, `ui` ou `word` : `AsterEditor`,
150
+ `AsterDocument`, `AsterCounts`, `AsterLocale`, `AsterTheme`, `LinkPolicy`,
151
+ `PasteTransformer`, `ToolbarConfig`, `ToolbarPreset`, `UploadHandler`, `AsterVariable`,
152
+ `VariableValues`, `VariableResolver`, `VariableSource`, `VariableRenderMode`, `GetHtmlOptions`,
153
+ `AsterUiOptions`, `DocxImportResult`, `DocxImportWarning`, `DocxExportOptions`,
154
+ `DocxExportVariableOptions`, `DocxExportWarning`, `DocxVariableMode`.
155
+
156
+ Un input dédié laissé à `undefined` n'écrase pas la valeur venue de `config` ou de
157
+ `provideAster`. Passe des objets **stables** dans `config` (champ de classe, pas un littéral dans
158
+ le template) : une nouvelle référence à chaque rendu relance la fusion, et une `toolbar`
159
+ déclarative recréée à chaque rendu remonterait l'éditeur.
160
+
161
+ ## ⚙️ provideAster
162
+
163
+ Configuration globale d'une application ou d'une route, fusionnée sous l'input `config` qui
164
+ est lui-même sous les inputs dédiés :
165
+
166
+ ```ts
167
+ import { ApplicationConfig } from '@angular/core';
168
+ import { provideAster } from '@aster-editor/angular';
169
+
170
+ export const appConfig: ApplicationConfig = {
171
+ providers: [
172
+ provideAster({
173
+ locale: 'en',
174
+ theme: 'night',
175
+ linkPolicy: { defaultTarget: '_blank' },
176
+ disabledCommands: ['fontFamily'],
177
+ }),
178
+ ],
179
+ };
180
+ ```
181
+
182
+ Le token sous-jacent est `ASTER_CONFIG` (`InjectionToken<Partial<AsterUiOptions>>`).
183
+
184
+ ## 🖼️ Upload d'images : ASTER_UPLOAD_HANDLER
185
+
186
+ Sans handler, les images collées, déposées ou importées sont insérées en data URL base64.
187
+ Pour passer par ton API :
188
+
189
+ ```ts
190
+ import { inject } from '@angular/core';
191
+ import { HttpClient } from '@angular/common/http';
192
+ import { firstValueFrom } from 'rxjs';
193
+ import { ASTER_UPLOAD_HANDLER, UploadHandler } from '@aster-editor/angular';
194
+
195
+ export const appConfig: ApplicationConfig = {
196
+ providers: [
197
+ {
198
+ provide: ASTER_UPLOAD_HANDLER,
199
+ useFactory: (): UploadHandler => {
200
+ const http = inject(HttpClient);
201
+ return async (file) => {
202
+ const body = new FormData();
203
+ body.append('file', file);
204
+ const { url } = await firstValueFrom(http.post<{ url: string }>('/api/upload', body));
205
+ return url;
206
+ };
207
+ },
208
+ },
209
+ ],
210
+ };
211
+ ```
212
+
213
+ Priorité : `config.upload` > `provideAster({ upload })` > `ASTER_UPLOAD_HANDLER`.
214
+
215
+ ## 📄 Import .docx
216
+
217
+ ```ts
218
+ @Component({
219
+ imports: [AsterEditorComponent],
220
+ template: `
221
+ <input type="file" accept=".docx" (change)="onFile($event)" />
222
+ <aster-editor />
223
+ `,
224
+ })
225
+ export class ImportComponent {
226
+ private readonly editor = viewChild.required(AsterEditorComponent);
227
+
228
+ async onFile(event: Event): Promise<void> {
229
+ const file = (event.target as HTMLInputElement).files?.[0];
230
+ if (!file) return;
231
+ const { warnings } = await this.editor().importDocx(file);
232
+ console.warn(warnings); // [{ code: 'mammoth', message: '…' }]
233
+ }
234
+ }
235
+ ```
236
+
237
+ `importDocx` charge `@aster-editor/word` par `import()` : mammoth reste dans un chunk séparé et
238
+ n'est téléchargé qu'au premier import. Le contenu est remplacé avec émission de `change`, un
239
+ formulaire lié voit donc la nouvelle valeur. Le handler d'upload de l'éditeur est réutilisé pour
240
+ les images embarquées. Appelé avant le montage, il rejette avec une erreur explicite.
241
+
242
+ > ⚠️ **Vite à froid** : sur `ng serve`, le tout premier `importDocx` d'une session déclenche
243
+ > l'optimisation de `mammoth` par Vite et **recharge la page** en plein import, sans erreur.
244
+ > C'est un comportement du serveur de dev, pas du build : relance l'import. Le test e2e de la
245
+ > démo fait de même (`toPass` avec nouvelle sélection du fichier).
246
+
247
+ ## 📤 Export .docx
248
+
249
+ Même recette que l'import, avec `exportDocx` et `downloadDocx` chargés à la demande (la lib
250
+ `docx` reste dans son chunk) :
251
+
252
+ ```ts
253
+ @Component({
254
+ selector: 'app-export',
255
+ imports: [AsterEditorComponent],
256
+ template: `
257
+ <aster-editor />
258
+ <button type="button" (click)="exportWord()">Exporter .docx</button>
259
+ `,
260
+ })
261
+ export class ExportComponent {
262
+ private readonly editor = viewChild.required(AsterEditorComponent);
263
+
264
+ async exportWord(): Promise<void> {
265
+ const editor = this.editor().editor();
266
+ if (!editor) return;
267
+ const { exportDocx, downloadDocx } = await import('@aster-editor/word');
268
+ const blob = await exportDocx(editor.getHTML(), { title: 'Article' });
269
+ downloadDocx(blob, 'article');
270
+ }
271
+ }
272
+ ```
273
+
274
+ Options, mapping HTML → Word et limites dans le [README word](../word/README.md).
275
+
276
+ ## 🧩 Variables et modèles
277
+
278
+ Les variables (champs de fusion `{{client.nom}}`) sont des puces du document, gérées par
279
+ `editor.variables` du core. Le composant les expose sans toucher à `config` :
280
+
281
+ | Input / output | Rôle |
282
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------- |
283
+ | `variables` | Déclarations (`name`, `label`, `description`, `value`) proposées dans le menu d'insertion |
284
+ | `variableValues` | Valeurs par nom. À chaud, fusionnées dans le magasin (`setValues`) |
285
+ | `variableResolver` | `(name, editor) => string \| undefined \| Promise<…>`, appelé par `resolveVariables()` |
286
+ | `allowUnknownVariables` | `false` pour refuser l'insertion d'un nom non déclaré (vrai par défaut : un `.docx` importé peut contenir n'importe quoi) |
287
+ | `variableSuggestion` | Menu déclenché par `{{` dans le contenu (vrai par défaut) |
288
+ | `variablesChanged` | Output `VariableValues`, émis sur `variablesChange` du core |
289
+ | `variableNames` | `Signal<readonly string[]>`, noms présents dans le document |
290
+
291
+ Ces inputs priment sur `config.variables` clé par clé ; un input laissé à `undefined` ne masque
292
+ rien. Un remontage (`toolbar`) conserve déclarations et valeurs.
293
+
294
+ `importDocx` déclare les variables trouvées dans le `.docx` (`{{nom}}`, champs `MERGEFIELD`,
295
+ contrôles de contenu avec leur libellé et leur valeur) via `editor.variables.define`, puis
296
+ réapplique `variableValues` sur les noms que Word avait valorisés : ton état reste la source de
297
+ vérité. `exportDocx` charge `@aster-editor/word` à la demande, exporte `getHTML()` et transmet les
298
+ valeurs du magasin quand `options.variables.values` n'est pas fourni :
299
+
300
+ - `variables.mode: 'resolve'` : valeurs substituées, `{{nom}}` conservé (et avertissement
301
+ `variable-unresolved` via `onWarning`) pour une variable sans valeur ;
302
+ - `'fields'` : champs de fusion Word, la valeur connue comme résultat de champ ;
303
+ - `'placeholders'` (défaut) : le modèle tel quel.
304
+
305
+ Exemple complet, formulaire généré depuis `variableNames` et export :
306
+
307
+ ```ts
308
+ @Component({
309
+ imports: [AsterEditorComponent],
310
+ template: `
311
+ <input type="file" accept=".docx" (change)="onFile($event)" />
312
+ <aster-editor
313
+ [variables]="declared"
314
+ [variableValues]="values()"
315
+ (variablesChanged)="store.set($event)"
316
+ />
317
+ <form (submit)="apply($event)">
318
+ @for (name of editor().variableNames(); track name) {
319
+ <label>
320
+ {{ name }}
321
+ <input [name]="name" [value]="store()[name] ?? ''" (input)="edit(name, $event)" />
322
+ </label>
323
+ }
324
+ <button type="submit">Appliquer</button>
325
+ </form>
326
+ <p>{{ unresolved() }} variable(s) sans valeur</p>
327
+ <button type="button" (click)="download('resolve')">Télécharger résolu</button>
328
+ <button type="button" (click)="download('fields')">Avec champs Word</button>
329
+ `,
330
+ })
331
+ export class TemplateComponent {
332
+ protected readonly editor = viewChild.required(AsterEditorComponent);
333
+ protected readonly declared: readonly AsterVariable[] = [{ name: 'client.nom', label: 'Client' }];
334
+ protected readonly values = signal<VariableValues | undefined>(undefined);
335
+ protected readonly store = signal<VariableValues>({});
336
+ protected readonly unresolved = computed(
337
+ () =>
338
+ this.editor()
339
+ .variableNames()
340
+ .filter((name) => !this.store()[name]).length,
341
+ );
342
+ private draft: Record<string, string> = {};
343
+
344
+ async onFile(event: Event): Promise<void> {
345
+ const file = (event.target as HTMLInputElement).files?.[0];
346
+ if (file) await this.editor().importDocx(file); // déclare les variables trouvées
347
+ }
348
+
349
+ edit(name: string, event: Event): void {
350
+ this.draft = { ...this.draft, [name]: (event.target as HTMLInputElement).value };
351
+ }
352
+
353
+ apply(event: Event): void {
354
+ event.preventDefault();
355
+ this.values.set({ ...this.draft }); // → editor.variables.setValues, puis variablesChanged
356
+ }
357
+
358
+ async download(mode: DocxVariableMode): Promise<void> {
359
+ const blob = await this.editor().exportDocx({ variables: { mode } });
360
+ const { downloadDocx } = await import('@aster-editor/word');
361
+ downloadDocx(blob, mode === 'resolve' ? 'contrat' : 'modele');
362
+ }
363
+ }
364
+ ```
365
+
366
+ Pour un aperçu résolu sans export, `editor().getHTML({ variables: 'resolve' })` rend le HTML
367
+ avec les valeurs substituées en texte brut ; `resolveVariables()` appelle d'abord le résolveur
368
+ pour les variables manquantes.
369
+
370
+ ## 📋 Collage depuis Word
371
+
372
+ Avec `wordPaste` (vrai par défaut), le composant charge `@aster-editor/word` au montage (jamais
373
+ côté serveur) et branche `wordPasteTransformer()` en tête des `pasteTransformers` : le HTML de
374
+ Word et de Google Docs est nettoyé avant d'atteindre l'éditeur, tes propres transformateurs
375
+ reçoivent du HTML propre. Si le paquet `word` n'est pas installé, un avertissement est journalisé
376
+ et le collage reste brut. Passer `wordPaste` à `false` laisse le module chargé mais inactif.
377
+
378
+ ## 🧭 App démo
379
+
380
+ `apps/demo` (`npm run demo`, http://localhost:4200) montre cinq pages chargées à la demande,
381
+ chacune avec son titre de document : `/` playground (preset, thème, langue, lecture seule,
382
+ compteurs, sortie HTML / JSON), `/form` reactive form avec validateur « requis » sur le contenu
383
+ riche, `/import` dépôt de `.docx` avec avertissements, `/readonly` rendu figé en thème `night`,
384
+ `/template` modèle à variables (exemple `08-variables-braces.docx` servi depuis
385
+ `public/samples/`, formulaire généré, aperçu puces / résolu, export dans les trois modes).
386
+
387
+ ## 🧪 Tests
388
+
389
+ ```bash
390
+ npx ng test aster-angular --watch=false # 39 tests du composant et de provideAster
391
+ npx ng test demo --watch=false # 23 tests des pages de la démo
392
+ npm run e2e -- --project=demo # tests Playwright sur la démo, page Modèle comprise (ng serve lancé par Playwright)
393
+ ```
394
+
395
+ TestBed + Vitest, environnement jsdom. Monte le composant, appelle `fixture.detectChanges()`
396
+ puis `await fixture.whenStable()` pour laisser `afterNextRender` créer l'éditeur.