@h-k-dev/angular-email-editor 0.0.0-stage → 0.0.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.
Files changed (51) hide show
  1. package/README.md +127 -2
  2. package/fesm2022/h-k-dev-angular-email-editor-actions.mjs +179 -0
  3. package/fesm2022/h-k-dev-angular-email-editor-actions.mjs.map +1 -0
  4. package/fesm2022/h-k-dev-angular-email-editor-address-chip.mjs +285 -0
  5. package/fesm2022/h-k-dev-angular-email-editor-address-chip.mjs.map +1 -0
  6. package/fesm2022/h-k-dev-angular-email-editor-address-input.mjs +686 -0
  7. package/fesm2022/h-k-dev-angular-email-editor-address-input.mjs.map +1 -0
  8. package/fesm2022/h-k-dev-angular-email-editor-address.mjs +489 -0
  9. package/fesm2022/h-k-dev-angular-email-editor-address.mjs.map +1 -0
  10. package/fesm2022/h-k-dev-angular-email-editor-anchor.mjs +64 -0
  11. package/fesm2022/h-k-dev-angular-email-editor-anchor.mjs.map +1 -0
  12. package/fesm2022/h-k-dev-angular-email-editor-attachment-chip.mjs +387 -0
  13. package/fesm2022/h-k-dev-angular-email-editor-attachment-chip.mjs.map +1 -0
  14. package/fesm2022/h-k-dev-angular-email-editor-attachment-chips.mjs +80 -0
  15. package/fesm2022/h-k-dev-angular-email-editor-attachment-chips.mjs.map +1 -0
  16. package/fesm2022/h-k-dev-angular-email-editor-chat-input.mjs +346 -0
  17. package/fesm2022/h-k-dev-angular-email-editor-chat-input.mjs.map +1 -0
  18. package/fesm2022/h-k-dev-angular-email-editor-focus.mjs +68 -0
  19. package/fesm2022/h-k-dev-angular-email-editor-focus.mjs.map +1 -0
  20. package/fesm2022/h-k-dev-angular-email-editor-image.mjs +132 -0
  21. package/fesm2022/h-k-dev-angular-email-editor-image.mjs.map +1 -0
  22. package/fesm2022/h-k-dev-angular-email-editor-material.mjs +124 -0
  23. package/fesm2022/h-k-dev-angular-email-editor-material.mjs.map +1 -0
  24. package/fesm2022/h-k-dev-angular-email-editor-metrics.mjs +243 -0
  25. package/fesm2022/h-k-dev-angular-email-editor-metrics.mjs.map +1 -0
  26. package/fesm2022/h-k-dev-angular-email-editor-palette.mjs +87 -0
  27. package/fesm2022/h-k-dev-angular-email-editor-palette.mjs.map +1 -0
  28. package/fesm2022/h-k-dev-angular-email-editor-proposal.mjs +307 -0
  29. package/fesm2022/h-k-dev-angular-email-editor-proposal.mjs.map +1 -0
  30. package/fesm2022/h-k-dev-angular-email-editor-suggestion-menu.mjs +392 -0
  31. package/fesm2022/h-k-dev-angular-email-editor-suggestion-menu.mjs.map +1 -0
  32. package/fesm2022/h-k-dev-angular-email-editor.mjs +18818 -0
  33. package/fesm2022/h-k-dev-angular-email-editor.mjs.map +1 -0
  34. package/package.json +133 -3
  35. package/styles/_scroll.scss +101 -0
  36. package/types/h-k-dev-angular-email-editor-actions.d.ts +147 -0
  37. package/types/h-k-dev-angular-email-editor-address-chip.d.ts +193 -0
  38. package/types/h-k-dev-angular-email-editor-address-input.d.ts +328 -0
  39. package/types/h-k-dev-angular-email-editor-address.d.ts +125 -0
  40. package/types/h-k-dev-angular-email-editor-anchor.d.ts +44 -0
  41. package/types/h-k-dev-angular-email-editor-attachment-chip.d.ts +271 -0
  42. package/types/h-k-dev-angular-email-editor-attachment-chips.d.ts +48 -0
  43. package/types/h-k-dev-angular-email-editor-chat-input.d.ts +101 -0
  44. package/types/h-k-dev-angular-email-editor-focus.d.ts +40 -0
  45. package/types/h-k-dev-angular-email-editor-image.d.ts +66 -0
  46. package/types/h-k-dev-angular-email-editor-material.d.ts +65 -0
  47. package/types/h-k-dev-angular-email-editor-metrics.d.ts +110 -0
  48. package/types/h-k-dev-angular-email-editor-palette.d.ts +51 -0
  49. package/types/h-k-dev-angular-email-editor-proposal.d.ts +170 -0
  50. package/types/h-k-dev-angular-email-editor-suggestion-menu.d.ts +177 -0
  51. package/types/h-k-dev-angular-email-editor.d.ts +3959 -0
@@ -0,0 +1,68 @@
1
+ import * as i0 from '@angular/core';
2
+ import { input, booleanAttribute, inject, ElementRef, Directive } from '@angular/core';
3
+
4
+ /** Where a press may still take focus by default: real text entry. A field
5
+ inside a toolbar has to be typed in; everything else there is a tool. */
6
+ const KEEP_FOCUS_EXCEPT = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
7
+ /**
8
+ * A press on this element never takes focus from wherever it is — the
9
+ * editor keeps its caret and its selection while a toolbar button, a menu
10
+ * row or a swatch is clicked, and whatever depends on the editor's focus (a
11
+ * bubble menu, a suggestion list) stays open through the click.
12
+ *
13
+ * Behaviour only: it renders nothing and sets no attribute, so it goes on a
14
+ * host's own elements — a Material button, an Aria toolbar, a CDK overlay's
15
+ * panel — or into a host's own component through `hostDirectives`:
16
+ *
17
+ * <div role="toolbar" emailKeepFocus> … <button mat-icon-button>…</button> … </div>
18
+ *
19
+ * @Component({ …, hostDirectives: [KeepFocus] })
20
+ *
21
+ * One on a container covers everything in it: the press bubbles. Only the
22
+ * pointer is kept out — Tab and the arrow keys still reach the tools, which
23
+ * is what makes them usable from the keyboard — and only the press: the
24
+ * click that follows runs as ever.
25
+ *
26
+ * It needs no editor: what keeps focus is whichever element has it, so the
27
+ * same directive serves a composer's address rows.
28
+ */
29
+ class KeepFocus {
30
+ /** Off with `[emailKeepFocus]="false"`; on by its presence. */
31
+ enabled = input(true, { ...(ngDevMode ? { debugName: "enabled" } : /* istanbul ignore next */ {}), alias: 'emailKeepFocus', transform: booleanAttribute });
32
+ /** What inside this element may still take focus on a press — a selector.
33
+ Text entry by default ({@link KEEP_FOCUS_EXCEPT}); `''` for nothing. */
34
+ except = input(KEEP_FOCUS_EXCEPT, { ...(ngDevMode ? { debugName: "except" } : /* istanbul ignore next */ {}), alias: 'emailKeepFocusExcept' });
35
+ #host = inject(ElementRef).nativeElement;
36
+ onMousedown(event) {
37
+ if (!this.enabled())
38
+ return;
39
+ const except = this.except();
40
+ const target = event.target;
41
+ // An exception counts inside this element only: standing in an editor's
42
+ // contenteditable must not make every press here an exception.
43
+ const excepted = except ? target?.closest?.(except) : null;
44
+ if (excepted && this.#host.contains(excepted))
45
+ return;
46
+ event.preventDefault();
47
+ }
48
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: KeepFocus, deps: [], target: i0.ɵɵFactoryTarget.Directive });
49
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.6", type: KeepFocus, isStandalone: true, selector: "[emailKeepFocus]", inputs: { enabled: { classPropertyName: "enabled", publicName: "emailKeepFocus", isSignal: true, isRequired: false, transformFunction: null }, except: { classPropertyName: "except", publicName: "emailKeepFocusExcept", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "mousedown": "onMousedown($event)" } }, ngImport: i0 });
50
+ }
51
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: KeepFocus, decorators: [{
52
+ type: Directive,
53
+ args: [{
54
+ selector: '[emailKeepFocus]',
55
+ host: { '(mousedown)': 'onMousedown($event)' },
56
+ }]
57
+ }], propDecorators: { enabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "emailKeepFocus", required: false }] }], except: [{ type: i0.Input, args: [{ isSignal: true, alias: "emailKeepFocusExcept", required: false }] }] } });
58
+
59
+ /*
60
+ * Public API Surface of @h-k-dev/angular-email-editor/focus
61
+ */
62
+
63
+ /**
64
+ * Generated bundle index. Do not edit.
65
+ */
66
+
67
+ export { KEEP_FOCUS_EXCEPT, KeepFocus };
68
+ //# sourceMappingURL=h-k-dev-angular-email-editor-focus.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"h-k-dev-angular-email-editor-focus.mjs","sources":["../../../projects/angular-email-editor/focus/keep-focus.ts","../../../projects/angular-email-editor/focus/public-api.ts","../../../projects/angular-email-editor/focus/h-k-dev-angular-email-editor-focus.ts"],"sourcesContent":["import { Directive, ElementRef, booleanAttribute, inject, input } from '@angular/core';\n\n/** Where a press may still take focus by default: real text entry. A field\n inside a toolbar has to be typed in; everything else there is a tool. */\nexport const KEEP_FOCUS_EXCEPT =\n 'input, textarea, select, [contenteditable]:not([contenteditable=\"false\"])';\n\n/**\n * A press on this element never takes focus from wherever it is — the\n * editor keeps its caret and its selection while a toolbar button, a menu\n * row or a swatch is clicked, and whatever depends on the editor's focus (a\n * bubble menu, a suggestion list) stays open through the click.\n *\n * Behaviour only: it renders nothing and sets no attribute, so it goes on a\n * host's own elements — a Material button, an Aria toolbar, a CDK overlay's\n * panel — or into a host's own component through `hostDirectives`:\n *\n * <div role=\"toolbar\" emailKeepFocus> … <button mat-icon-button>…</button> … </div>\n *\n * @Component({ …, hostDirectives: [KeepFocus] })\n *\n * One on a container covers everything in it: the press bubbles. Only the\n * pointer is kept out — Tab and the arrow keys still reach the tools, which\n * is what makes them usable from the keyboard — and only the press: the\n * click that follows runs as ever.\n *\n * It needs no editor: what keeps focus is whichever element has it, so the\n * same directive serves a composer's address rows.\n */\n@Directive({\n selector: '[emailKeepFocus]',\n host: { '(mousedown)': 'onMousedown($event)' },\n})\nexport class KeepFocus {\n /** Off with `[emailKeepFocus]=\"false\"`; on by its presence. */\n readonly enabled = input(true, { alias: 'emailKeepFocus', transform: booleanAttribute });\n\n /** What inside this element may still take focus on a press — a selector.\n Text entry by default ({@link KEEP_FOCUS_EXCEPT}); `''` for nothing. */\n readonly except = input(KEEP_FOCUS_EXCEPT, { alias: 'emailKeepFocusExcept' });\n\n readonly #host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;\n\n protected onMousedown(event: MouseEvent): void {\n if (!this.enabled()) return;\n const except = this.except();\n const target = event.target as Element | null;\n // An exception counts inside this element only: standing in an editor's\n // contenteditable must not make every press here an exception.\n const excepted = except ? target?.closest?.(except) : null;\n if (excepted && this.#host.contains(excepted)) return;\n event.preventDefault();\n }\n}\n","/*\n * Public API Surface of @h-k-dev/angular-email-editor/focus\n */\n\nexport * from './keep-focus';\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":";;;AAEA;AAC4E;AACrE,MAAM,iBAAiB,GAC5B;AAEF;;;;;;;;;;;;;;;;;;;;;AAqBG;MAKU,SAAS,CAAA;;AAEX,IAAA,OAAO,GAAG,KAAK,CAAC,IAAI,EAAA,EAAA,IAAA,SAAA,GAAA,EAAA,SAAA,EAAA,SAAA,EAAA,8BAAA,EAAA,CAAA,EAAI,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,gBAAgB,GAAG;AAExF;AAC2E;IAClE,MAAM,GAAG,KAAK,CAAC,iBAAiB,8EAAI,KAAK,EAAE,sBAAsB,EAAA,CAAG;AAEpE,IAAA,KAAK,GAAG,MAAM,CAA0B,UAAU,CAAC,CAAC,aAAa;AAEhE,IAAA,WAAW,CAAC,KAAiB,EAAA;AACrC,QAAA,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE;YAAE;AACrB,QAAA,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE;AAC5B,QAAA,MAAM,MAAM,GAAG,KAAK,CAAC,MAAwB;;;AAG7C,QAAA,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC,GAAG,IAAI;QAC1D,IAAI,QAAQ,IAAI,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC;YAAE;QAC/C,KAAK,CAAC,cAAc,EAAE;IACxB;uGAnBW,SAAS,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA;2FAAT,SAAS,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,kBAAA,EAAA,MAAA,EAAA,EAAA,OAAA,EAAA,EAAA,iBAAA,EAAA,SAAA,EAAA,UAAA,EAAA,gBAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,MAAA,EAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,UAAA,EAAA,sBAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,EAAA,IAAA,EAAA,EAAA,SAAA,EAAA,EAAA,WAAA,EAAA,qBAAA,EAAA,EAAA,EAAA,QAAA,EAAA,EAAA,EAAA,CAAA;;2FAAT,SAAS,EAAA,UAAA,EAAA,CAAA;kBAJrB,SAAS;AAAC,YAAA,IAAA,EAAA,CAAA;AACT,oBAAA,QAAQ,EAAE,kBAAkB;AAC5B,oBAAA,IAAI,EAAE,EAAE,aAAa,EAAE,qBAAqB,EAAE;AAC/C,iBAAA;;;AChCD;;AAEG;;ACFH;;AAEG;;;;"}
@@ -0,0 +1,132 @@
1
+ import * as i0 from '@angular/core';
2
+ import { input, booleanAttribute, output, inject, ElementRef, computed, linkedSignal, afterNextRender, Directive } from '@angular/core';
3
+ import { selectedImage, selectedImageAlt } from '@h-k-dev/angular-email-editor';
4
+ import { editorState } from '@h-k-dev/angular-email-editor/actions';
5
+
6
+ /**
7
+ * Makes the host's own text field the alt text of the editor's selected
8
+ * image — the image a click selected, or a drag over it alone
9
+ * (`selectedImage`). Everything but the field and its words is done:
10
+ *
11
+ * <input dir="auto" [emailImageAlt]="editor" #alt="emailImageAlt"
12
+ * (emailImageAltClosed)="open.set(false)" />
13
+ * <button (click)="alt.apply()">Apply</button>
14
+ *
15
+ * - the field shows the image's alt, and again whenever another image is
16
+ * selected; it is focused and selected as it appears, so typing replaces
17
+ * it (`[emailImageAltAutoSelect]="false"` for a field that is always
18
+ * there, a side panel's);
19
+ * - Enter applies — trimmed, and an emptied field takes the alt off — and
20
+ * Escape puts the alt back as it was; both hand the caret back to the
21
+ * editor (`[emailImageAltFocus]="false"` keeps it) and say so through
22
+ * `emailImageAltClosed`: `true` applied, `false` not — the host's cue to
23
+ * close its popover. Neither fires mid-composition: an IME's own Enter
24
+ * and Escape stay the IME's;
25
+ * - `apply()` and `cancel()` are there for the host's buttons, `value()`
26
+ * and `active()` for its bindings (an Apply that waits for a change).
27
+ *
28
+ * The field's words and direction are the host's: its label, its
29
+ * placeholder, and `dir="auto"` so an alt in a right-to-left script reads
30
+ * the right way round. The image stays selected throughout — the editor
31
+ * keeps its selection while the field has focus — so the popover can sit
32
+ * where the host likes.
33
+ */
34
+ class ImageAlt {
35
+ /** The editor whose selected image this field describes. */
36
+ editor = input.required({ ...(ngDevMode ? { debugName: "editor" } : /* istanbul ignore next */ {}), alias: 'emailImageAlt' });
37
+ /** Whether the field takes focus, its text selected, as it appears. */
38
+ autoSelect = input(true, { ...(ngDevMode ? { debugName: "autoSelect" } : /* istanbul ignore next */ {}), alias: 'emailImageAltAutoSelect',
39
+ transform: booleanAttribute });
40
+ /** Whether the caret returns to the editor after an apply or a cancel. */
41
+ returnFocus = input(true, { ...(ngDevMode ? { debugName: "returnFocus" } : /* istanbul ignore next */ {}), alias: 'emailImageAltFocus', transform: booleanAttribute });
42
+ /** Done with the field: `true` when the alt was applied, `false` when
43
+ not — cancelled, or no image to apply it to. */
44
+ closed = output({ alias: 'emailImageAltClosed' });
45
+ #element = inject(ElementRef)
46
+ .nativeElement;
47
+ #state = editorState(() => this.editor());
48
+ /** The selected image — where it is and what its alt says — or null. */
49
+ #image = computed(() => {
50
+ const state = this.#state();
51
+ const image = state && selectedImage(state);
52
+ return image ? { pos: image.pos, alt: selectedImageAlt(state) ?? '' } : null;
53
+ }, { ...(ngDevMode ? { debugName: "#image" } : /* istanbul ignore next */ {}), equal: (a, b) => a?.pos === b?.pos && a?.alt === b?.alt });
54
+ /** Whether an image is selected at all — there is nothing to describe
55
+ otherwise, and `apply()` does nothing. */
56
+ active = computed(() => this.#image() !== null, /* @ts-ignore */
57
+ ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
58
+ /**
59
+ * What the field says: the selected image's alt, until typing changes it
60
+ * — the draft. It starts over from the image whenever another one is
61
+ * selected or its alt changes elsewhere; typing changes no editor state,
62
+ * so it is never written over. Deliberately no write-back setter: the
63
+ * draft reaches the document once, on `apply()`, as one undo step — not
64
+ * a transaction per keystroke.
65
+ */
66
+ value = linkedSignal(() => this.#image()?.alt ?? '', /* @ts-ignore */
67
+ ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
68
+ constructor() {
69
+ // Focus is a DOM write, after the render that put the field (and its
70
+ // bound value) on the page.
71
+ afterNextRender({
72
+ write: () => {
73
+ if (!this.autoSelect())
74
+ return;
75
+ this.#element.focus();
76
+ this.#element.select();
77
+ },
78
+ });
79
+ }
80
+ /** Writes the draft to the selected image's alt. False when no image is
81
+ selected. */
82
+ apply() {
83
+ const applied = !!this.editor()?.commands['setImageAlt']?.(this.value());
84
+ this.#done(applied);
85
+ return applied;
86
+ }
87
+ /** Leaves the alt as it was, and the field with it. */
88
+ cancel() {
89
+ this.value.set(this.#image()?.alt ?? '');
90
+ this.#done(false);
91
+ }
92
+ onKey(event, apply) {
93
+ if (event.isComposing)
94
+ return;
95
+ event.preventDefault();
96
+ if (apply)
97
+ this.apply();
98
+ else
99
+ this.cancel();
100
+ }
101
+ #done(applied) {
102
+ if (this.returnFocus())
103
+ this.editor()?.focus();
104
+ this.closed.emit(applied);
105
+ }
106
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: ImageAlt, deps: [], target: i0.ɵɵFactoryTarget.Directive });
107
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.6", type: ImageAlt, isStandalone: true, selector: "input[emailImageAlt], textarea[emailImageAlt]", inputs: { editor: { classPropertyName: "editor", publicName: "emailImageAlt", isSignal: true, isRequired: true, transformFunction: null }, autoSelect: { classPropertyName: "autoSelect", publicName: "emailImageAltAutoSelect", isSignal: true, isRequired: false, transformFunction: null }, returnFocus: { classPropertyName: "returnFocus", publicName: "emailImageAltFocus", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { closed: "emailImageAltClosed" }, host: { listeners: { "input": "value.set($any($event.target).value)", "keydown.enter": "onKey($event, true)", "keydown.escape": "onKey($event, false)" }, properties: { "value": "value()" } }, exportAs: ["emailImageAlt"], ngImport: i0 });
108
+ }
109
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: ImageAlt, decorators: [{
110
+ type: Directive,
111
+ args: [{
112
+ selector: 'input[emailImageAlt], textarea[emailImageAlt]',
113
+ exportAs: 'emailImageAlt',
114
+ host: {
115
+ '[value]': 'value()',
116
+ '(input)': 'value.set($any($event.target).value)',
117
+ '(keydown.enter)': 'onKey($event, true)',
118
+ '(keydown.escape)': 'onKey($event, false)',
119
+ },
120
+ }]
121
+ }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "emailImageAlt", required: true }] }], autoSelect: [{ type: i0.Input, args: [{ isSignal: true, alias: "emailImageAltAutoSelect", required: false }] }], returnFocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "emailImageAltFocus", required: false }] }], closed: [{ type: i0.Output, args: ["emailImageAltClosed"] }] } });
122
+
123
+ /*
124
+ * Public API Surface of @h-k-dev/angular-email-editor/image
125
+ */
126
+
127
+ /**
128
+ * Generated bundle index. Do not edit.
129
+ */
130
+
131
+ export { ImageAlt };
132
+ //# sourceMappingURL=h-k-dev-angular-email-editor-image.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"h-k-dev-angular-email-editor-image.mjs","sources":["../../../projects/angular-email-editor/image/image-alt.ts","../../../projects/angular-email-editor/image/public-api.ts","../../../projects/angular-email-editor/image/h-k-dev-angular-email-editor-image.ts"],"sourcesContent":["import {\n Directive,\n ElementRef,\n afterNextRender,\n booleanAttribute,\n computed,\n inject,\n input,\n linkedSignal,\n output,\n} from '@angular/core';\nimport { Editor, selectedImage, selectedImageAlt } from '@h-k-dev/angular-email-editor';\nimport { editorState } from '@h-k-dev/angular-email-editor/actions';\n\n/**\n * Makes the host's own text field the alt text of the editor's selected\n * image — the image a click selected, or a drag over it alone\n * (`selectedImage`). Everything but the field and its words is done:\n *\n * <input dir=\"auto\" [emailImageAlt]=\"editor\" #alt=\"emailImageAlt\"\n * (emailImageAltClosed)=\"open.set(false)\" />\n * <button (click)=\"alt.apply()\">Apply</button>\n *\n * - the field shows the image's alt, and again whenever another image is\n * selected; it is focused and selected as it appears, so typing replaces\n * it (`[emailImageAltAutoSelect]=\"false\"` for a field that is always\n * there, a side panel's);\n * - Enter applies — trimmed, and an emptied field takes the alt off — and\n * Escape puts the alt back as it was; both hand the caret back to the\n * editor (`[emailImageAltFocus]=\"false\"` keeps it) and say so through\n * `emailImageAltClosed`: `true` applied, `false` not — the host's cue to\n * close its popover. Neither fires mid-composition: an IME's own Enter\n * and Escape stay the IME's;\n * - `apply()` and `cancel()` are there for the host's buttons, `value()`\n * and `active()` for its bindings (an Apply that waits for a change).\n *\n * The field's words and direction are the host's: its label, its\n * placeholder, and `dir=\"auto\"` so an alt in a right-to-left script reads\n * the right way round. The image stays selected throughout — the editor\n * keeps its selection while the field has focus — so the popover can sit\n * where the host likes.\n */\n@Directive({\n selector: 'input[emailImageAlt], textarea[emailImageAlt]',\n exportAs: 'emailImageAlt',\n host: {\n '[value]': 'value()',\n '(input)': 'value.set($any($event.target).value)',\n '(keydown.enter)': 'onKey($event, true)',\n '(keydown.escape)': 'onKey($event, false)',\n },\n})\nexport class ImageAlt {\n /** The editor whose selected image this field describes. */\n readonly editor = input.required<Editor | undefined>({ alias: 'emailImageAlt' });\n\n /** Whether the field takes focus, its text selected, as it appears. */\n readonly autoSelect = input(true, {\n alias: 'emailImageAltAutoSelect',\n transform: booleanAttribute,\n });\n\n /** Whether the caret returns to the editor after an apply or a cancel. */\n readonly returnFocus = input(true, { alias: 'emailImageAltFocus', transform: booleanAttribute });\n\n /** Done with the field: `true` when the alt was applied, `false` when\n not — cancelled, or no image to apply it to. */\n readonly closed = output<boolean>({ alias: 'emailImageAltClosed' });\n\n readonly #element = inject<ElementRef<HTMLInputElement | HTMLTextAreaElement>>(ElementRef)\n .nativeElement;\n\n readonly #state = editorState(() => this.editor());\n\n /** The selected image — where it is and what its alt says — or null. */\n readonly #image = computed(\n () => {\n const state = this.#state();\n const image = state && selectedImage(state);\n return image ? { pos: image.pos, alt: selectedImageAlt(state) ?? '' } : null;\n },\n { equal: (a, b) => a?.pos === b?.pos && a?.alt === b?.alt },\n );\n\n /** Whether an image is selected at all — there is nothing to describe\n otherwise, and `apply()` does nothing. */\n readonly active = computed(() => this.#image() !== null);\n\n /**\n * What the field says: the selected image's alt, until typing changes it\n * — the draft. It starts over from the image whenever another one is\n * selected or its alt changes elsewhere; typing changes no editor state,\n * so it is never written over. Deliberately no write-back setter: the\n * draft reaches the document once, on `apply()`, as one undo step — not\n * a transaction per keystroke.\n */\n readonly value = linkedSignal(() => this.#image()?.alt ?? '');\n\n constructor() {\n // Focus is a DOM write, after the render that put the field (and its\n // bound value) on the page.\n afterNextRender({\n write: () => {\n if (!this.autoSelect()) return;\n this.#element.focus();\n this.#element.select();\n },\n });\n }\n\n /** Writes the draft to the selected image's alt. False when no image is\n selected. */\n apply(): boolean {\n const applied = !!this.editor()?.commands['setImageAlt']?.(this.value());\n this.#done(applied);\n return applied;\n }\n\n /** Leaves the alt as it was, and the field with it. */\n cancel(): void {\n this.value.set(this.#image()?.alt ?? '');\n this.#done(false);\n }\n\n protected onKey(event: Event, apply: boolean): void {\n if ((event as KeyboardEvent).isComposing) return;\n event.preventDefault();\n if (apply) this.apply();\n else this.cancel();\n }\n\n #done(applied: boolean): void {\n if (this.returnFocus()) this.editor()?.focus();\n this.closed.emit(applied);\n }\n}\n","/*\n * Public API Surface of @h-k-dev/angular-email-editor/image\n */\n\nexport * from './image-alt';\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,124 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, effect, DestroyRef, Directive } from '@angular/core';
3
+ import { MatFormFieldControl } from '@angular/material/form-field';
4
+ import { Subject } from 'rxjs';
5
+ import { AddressInput } from '@h-k-dev/angular-email-editor/address-input';
6
+
7
+ /**
8
+ * Makes an address input the control of an Angular Material form field —
9
+ * opt in per field, with the attribute:
10
+ *
11
+ * ```html
12
+ * <mat-form-field>
13
+ * <mat-label>To</mat-label>
14
+ * <div email-address-input emailMatFormField [formField]="envelope.to"></div>
15
+ * <mat-hint>Separate addresses with a comma</mat-hint>
16
+ * <mat-error>{{ envelope.to().errors()[0]?.message }}</mat-error>
17
+ * </mat-form-field>
18
+ * ```
19
+ *
20
+ * The directive is its own object, provided as the field's
21
+ * `MatFormFieldControl` — so the input's `value`, `disabled` and `required`
22
+ * stay the signal-forms contract they are, and nothing of Material reaches
23
+ * the input itself. What the field asks, it reads off the input:
24
+ *
25
+ * - **Label** — `for` the input's text field (`id`); it floats while the
26
+ * field is focused or not `empty`: no chips *and* nothing typed.
27
+ * - **Errors** — `errorState` is invalid *and* touched: `mat-error` shows at
28
+ * the moment the input starts flagging itself, never before.
29
+ * - **Hints and errors are read out** — their ids join the text field's
30
+ * `aria-describedby`, beside any the host set itself.
31
+ * - **A click on the field** — anywhere in its box — puts the caret in.
32
+ *
33
+ * No `NgControl`: signal forms bind through `[formField]`, which the field
34
+ * does not need to know.
35
+ *
36
+ * The field's value is the input's — one header-form entry per recipient.
37
+ * A backend that keeps the header as one string gets it with
38
+ * `joinAddresses(list)` and hands it back through `splitAddresses(text)`
39
+ * (`@h-k-dev/angular-email-editor/address`); the pair round-trips entry for entry.
40
+ */
41
+ class AddressFormField {
42
+ #input = inject(AddressInput);
43
+ /** Tells the field to look again — whenever anything it reads moves. */
44
+ stateChanges = new Subject();
45
+ controlType = 'email-address-input';
46
+ ngControl = null;
47
+ constructor() {
48
+ effect(() => {
49
+ // Everything the field reads, tracked: a change of any is news.
50
+ this.#input.value();
51
+ this.#input.text();
52
+ this.#input.focused();
53
+ this.#input.placeholder();
54
+ this.#input.required();
55
+ this.#input.disabled();
56
+ this.#input.invalid();
57
+ this.#input.touched();
58
+ this.stateChanges.next();
59
+ });
60
+ inject(DestroyRef).onDestroy(() => this.stateChanges.complete());
61
+ }
62
+ get value() {
63
+ return this.#input.value();
64
+ }
65
+ set value(value) {
66
+ this.#input.value.set(value ?? []);
67
+ }
68
+ get id() {
69
+ return this.#input.id;
70
+ }
71
+ get placeholder() {
72
+ return this.#input.placeholder();
73
+ }
74
+ get focused() {
75
+ return this.#input.focused();
76
+ }
77
+ get empty() {
78
+ return !this.#input.value().length && !this.#input.text();
79
+ }
80
+ get shouldLabelFloat() {
81
+ return this.focused || !this.empty;
82
+ }
83
+ get required() {
84
+ return this.#input.required();
85
+ }
86
+ get disabled() {
87
+ return this.#input.disabled();
88
+ }
89
+ get errorState() {
90
+ return this.#input.invalid() && this.#input.touched();
91
+ }
92
+ /** The host's own `aria-describedby`, which the field keeps in its list. */
93
+ get userAriaDescribedBy() {
94
+ return this.#input.ariaDescribedby() ?? undefined;
95
+ }
96
+ describedByIds = [];
97
+ setDescribedByIds(ids) {
98
+ this.describedByIds = ids;
99
+ this.#input.describe(ids);
100
+ }
101
+ onContainerClick() {
102
+ this.#input.focus();
103
+ }
104
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: AddressFormField, deps: [], target: i0.ɵɵFactoryTarget.Directive });
105
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.6", type: AddressFormField, isStandalone: true, selector: "[email-address-input][emailMatFormField]", providers: [{ provide: MatFormFieldControl, useExisting: AddressFormField }], ngImport: i0 });
106
+ }
107
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: AddressFormField, decorators: [{
108
+ type: Directive,
109
+ args: [{
110
+ selector: '[email-address-input][emailMatFormField]',
111
+ providers: [{ provide: MatFormFieldControl, useExisting: AddressFormField }],
112
+ }]
113
+ }], ctorParameters: () => [] });
114
+
115
+ /*
116
+ * Public API Surface of @h-k-dev/angular-email-editor/material
117
+ */
118
+
119
+ /**
120
+ * Generated bundle index. Do not edit.
121
+ */
122
+
123
+ export { AddressFormField };
124
+ //# sourceMappingURL=h-k-dev-angular-email-editor-material.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"h-k-dev-angular-email-editor-material.mjs","sources":["../../../projects/angular-email-editor/material/address-form-field.ts","../../../projects/angular-email-editor/material/public-api.ts","../../../projects/angular-email-editor/material/h-k-dev-angular-email-editor-material.ts"],"sourcesContent":["import { DestroyRef, Directive, effect, inject } from '@angular/core';\nimport { MatFormFieldControl } from '@angular/material/form-field';\nimport { Subject } from 'rxjs';\nimport { AddressInput } from '@h-k-dev/angular-email-editor/address-input';\n\n/**\n * Makes an address input the control of an Angular Material form field —\n * opt in per field, with the attribute:\n *\n * ```html\n * <mat-form-field>\n * <mat-label>To</mat-label>\n * <div email-address-input emailMatFormField [formField]=\"envelope.to\"></div>\n * <mat-hint>Separate addresses with a comma</mat-hint>\n * <mat-error>{{ envelope.to().errors()[0]?.message }}</mat-error>\n * </mat-form-field>\n * ```\n *\n * The directive is its own object, provided as the field's\n * `MatFormFieldControl` — so the input's `value`, `disabled` and `required`\n * stay the signal-forms contract they are, and nothing of Material reaches\n * the input itself. What the field asks, it reads off the input:\n *\n * - **Label** — `for` the input's text field (`id`); it floats while the\n * field is focused or not `empty`: no chips *and* nothing typed.\n * - **Errors** — `errorState` is invalid *and* touched: `mat-error` shows at\n * the moment the input starts flagging itself, never before.\n * - **Hints and errors are read out** — their ids join the text field's\n * `aria-describedby`, beside any the host set itself.\n * - **A click on the field** — anywhere in its box — puts the caret in.\n *\n * No `NgControl`: signal forms bind through `[formField]`, which the field\n * does not need to know.\n *\n * The field's value is the input's — one header-form entry per recipient.\n * A backend that keeps the header as one string gets it with\n * `joinAddresses(list)` and hands it back through `splitAddresses(text)`\n * (`@h-k-dev/angular-email-editor/address`); the pair round-trips entry for entry.\n */\n@Directive({\n selector: '[email-address-input][emailMatFormField]',\n providers: [{ provide: MatFormFieldControl, useExisting: AddressFormField }],\n})\nexport class AddressFormField implements MatFormFieldControl<string[]> {\n readonly #input = inject(AddressInput);\n\n /** Tells the field to look again — whenever anything it reads moves. */\n readonly stateChanges = new Subject<void>();\n\n readonly controlType = 'email-address-input';\n readonly ngControl = null;\n\n constructor() {\n effect(() => {\n // Everything the field reads, tracked: a change of any is news.\n this.#input.value();\n this.#input.text();\n this.#input.focused();\n this.#input.placeholder();\n this.#input.required();\n this.#input.disabled();\n this.#input.invalid();\n this.#input.touched();\n this.stateChanges.next();\n });\n inject(DestroyRef).onDestroy(() => this.stateChanges.complete());\n }\n\n get value(): string[] {\n return this.#input.value();\n }\n\n set value(value: string[] | null) {\n this.#input.value.set(value ?? []);\n }\n\n get id(): string {\n return this.#input.id;\n }\n\n get placeholder(): string {\n return this.#input.placeholder();\n }\n\n get focused(): boolean {\n return this.#input.focused();\n }\n\n get empty(): boolean {\n return !this.#input.value().length && !this.#input.text();\n }\n\n get shouldLabelFloat(): boolean {\n return this.focused || !this.empty;\n }\n\n get required(): boolean {\n return this.#input.required();\n }\n\n get disabled(): boolean {\n return this.#input.disabled();\n }\n\n get errorState(): boolean {\n return this.#input.invalid() && this.#input.touched();\n }\n\n /** The host's own `aria-describedby`, which the field keeps in its list. */\n get userAriaDescribedBy(): string | undefined {\n return this.#input.ariaDescribedby() ?? undefined;\n }\n\n describedByIds: string[] = [];\n\n setDescribedByIds(ids: string[]): void {\n this.describedByIds = ids;\n this.#input.describe(ids);\n }\n\n onContainerClick(): void {\n this.#input.focus();\n }\n}\n","/*\n * Public API Surface of @h-k-dev/angular-email-editor/material\n */\n\nexport * from './address-form-field';\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,243 @@
1
+ import { layout, prepare } from '@chenglou/pretext';
2
+ import { measureRichInlineStats, prepareRichInline } from '@chenglou/pretext/rich-inline';
3
+ import { Plugin, PluginKey } from 'prosemirror-state';
4
+ import { defineExtension } from '@h-k-dev/angular-email-editor';
5
+
6
+ /** Browser default h1-h6 sizes; the editor stylesheet doesn't restyle them. */
7
+ const HEADING_SCALE = [2, 1.5, 1.17, 1, 0.83, 0.67];
8
+ const cssPx = (style, property, fallback) => {
9
+ const value = parseFloat(style.getPropertyValue(property));
10
+ return Number.isFinite(value) ? value : fallback;
11
+ };
12
+ /**
13
+ * Reads the typography pretext needs from the editor's computed style. One
14
+ * DOM read at startup (and again once webfonts settle) — never per keystroke.
15
+ */
16
+ function readTypography(dom) {
17
+ const style = getComputedStyle(dom);
18
+ const fontSize = parseFloat(style.fontSize) || 16;
19
+ // `line-height: normal` parses as NaN; browsers render it at roughly 1.2.
20
+ const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.2;
21
+ const innerSpacing = cssPx(style, '--mat-sys-inner-spacing', 16);
22
+ return {
23
+ // Pretext refuses a font string the canvas would ignore: an editor with
24
+ // no family in its computed style (detached, unstyled) measures in the
25
+ // browser's sans-serif rather than not at all.
26
+ fontFamily: style.fontFamily.trim() || 'sans-serif',
27
+ fontSize,
28
+ fontWeight: parseInt(style.fontWeight, 10) || 400,
29
+ fontStyle: style.fontStyle || 'normal',
30
+ lineHeight,
31
+ blockGap: cssPx(style, '--prose-gap', fontSize),
32
+ headingScale: HEADING_SCALE,
33
+ indents: {
34
+ blockquote: innerSpacing + 3, // padding-inline-start + border
35
+ list: 1.5 * fontSize, // ul/ol padding-inline-start
36
+ listItemGap: 0.25 * fontSize, // li + li margin-block-start
37
+ },
38
+ };
39
+ }
40
+ const createBlockMeasureCache = () => new WeakMap();
41
+ /**
42
+ * Measures the whole document at the given content width. Pure arithmetic
43
+ * over cached glyph widths — no DOM access.
44
+ */
45
+ function measureDoc(doc, contentWidth, typography, cache) {
46
+ const metrics = { words: 0, characters: 0, lines: 0, estimatedHeight: 0 };
47
+ const width = Math.max(contentWidth, 1);
48
+ const visitChildren = (parent, availableWidth, gap) => {
49
+ let index = 0;
50
+ parent.forEach((child) => {
51
+ if (index++ > 0)
52
+ metrics.estimatedHeight += gap;
53
+ visit(child, availableWidth);
54
+ });
55
+ };
56
+ const visit = (node, availableWidth) => {
57
+ if (node.isTextblock) {
58
+ const measured = measureTextblock(node, availableWidth, typography, cache);
59
+ metrics.words += measured.words;
60
+ metrics.characters += measured.characters;
61
+ metrics.lines += measured.lastLines;
62
+ metrics.estimatedHeight += measured.lastLines * measured.lineHeight;
63
+ return;
64
+ }
65
+ switch (node.type.name) {
66
+ case 'blockquote':
67
+ visitChildren(node, availableWidth - typography.indents.blockquote, 0);
68
+ break;
69
+ case 'bulletList':
70
+ case 'orderedList':
71
+ visitChildren(node, availableWidth - typography.indents.list, typography.indents.listItemGap);
72
+ break;
73
+ default:
74
+ // List items and future wrappers: measure content at full width.
75
+ if (node.childCount)
76
+ visitChildren(node, availableWidth, 0);
77
+ break;
78
+ }
79
+ };
80
+ visitChildren(doc, width, typography.blockGap);
81
+ metrics.estimatedHeight = Math.round(metrics.estimatedHeight);
82
+ return metrics;
83
+ }
84
+ function measureTextblock(node, width, typography, cache) {
85
+ const maxWidth = Math.max(width, 1);
86
+ let cached = cache.get(node);
87
+ if (!cached) {
88
+ cached = prepareBlock(node, typography);
89
+ cache.set(node, cached);
90
+ }
91
+ if (cached.lastWidth !== maxWidth) {
92
+ cached.lastLines = countLines(cached.block, maxWidth, cached.lineHeight);
93
+ cached.lastWidth = maxWidth;
94
+ }
95
+ return cached;
96
+ }
97
+ function countLines(block, maxWidth, lineHeight) {
98
+ if (block.kind === 'plain') {
99
+ // Browsers size an empty block to one line-height; pretext reports 0.
100
+ return Math.max(1, layout(block.prepared, maxWidth, lineHeight).lineCount);
101
+ }
102
+ return block.segments.reduce((lines, segment) => lines + Math.max(1, measureRichInlineStats(segment, maxWidth).lineCount), 0);
103
+ }
104
+ function blockFontFor(node, t) {
105
+ if (node.type.name === 'heading') {
106
+ const level = node.attrs['level'] ?? 1;
107
+ const size = t.fontSize * (t.headingScale[level - 1] ?? 1);
108
+ return {
109
+ size,
110
+ weight: 700,
111
+ style: t.fontStyle,
112
+ lineHeight: size * (t.lineHeight / t.fontSize),
113
+ };
114
+ }
115
+ return { size: t.fontSize, weight: t.fontWeight, style: t.fontStyle, lineHeight: t.lineHeight };
116
+ }
117
+ /** Canvas `font` shorthand, the format pretext's `prepare()` expects. */
118
+ const fontShorthand = (style, weight, size, family) => `${style === 'normal' ? '' : `${style} `}${weight} ${size}px ${family}`;
119
+ function prepareBlock(node, typography) {
120
+ const base = blockFontFor(node, typography);
121
+ const baseFont = fontShorthand(base.style, base.weight, base.size, typography.fontFamily);
122
+ // Inline runs split on hard breaks; each segment lays out as its own line(s).
123
+ const segments = [[]];
124
+ let words = 0;
125
+ let characters = 0;
126
+ let mixedFonts = false;
127
+ node.forEach((child) => {
128
+ if (child.type.name === 'hardBreak') {
129
+ segments.push([]);
130
+ return;
131
+ }
132
+ if (!child.isText || !child.text)
133
+ return;
134
+ const bold = child.marks.some((mark) => mark.type.name === 'bold');
135
+ const italic = child.marks.some((mark) => mark.type.name === 'italic');
136
+ const font = bold || italic
137
+ ? fontShorthand(italic ? 'italic' : base.style, bold ? 700 : base.weight, base.size, typography.fontFamily)
138
+ : baseFont;
139
+ if (font !== baseFont)
140
+ mixedFonts = true;
141
+ segments[segments.length - 1].push({ text: child.text, font });
142
+ words += child.text.split(/\s+/).filter(Boolean).length;
143
+ characters += child.text.length;
144
+ });
145
+ const block = mixedFonts
146
+ ? {
147
+ kind: 'rich',
148
+ segments: segments.map((runs) => prepareRichInline(runs, { whiteSpace: 'pre-wrap' })),
149
+ }
150
+ : {
151
+ // Single font: `prepare()` with pre-wrap mirrors the editor CSS
152
+ // exactly, and hard breaks fold into the text as `\n`.
153
+ kind: 'plain',
154
+ prepared: prepare(segments.map((runs) => runs.map((run) => run.text).join('')).join('\n'), baseFont, { whiteSpace: 'pre-wrap' }),
155
+ };
156
+ return { block, lineHeight: base.lineHeight, words, characters, lastWidth: -1, lastLines: 0 };
157
+ }
158
+
159
+ /**
160
+ * Live document metrics (words, lines, estimated height) computed with
161
+ * `@chenglou/pretext` — pure arithmetic over cached glyph widths, no DOM
162
+ * measurement on the keystroke path. Unchanged blocks are never re-measured:
163
+ * the cache keys on ProseMirror's immutable nodes.
164
+ *
165
+ * Metrics are an extra, never a dependency of the editor: a measurement
166
+ * that throws is reported through `onError` and skipped, and without a
167
+ * `ResizeObserver` the width read at mount stands.
168
+ */
169
+ const createTextMetrics = (options) => defineExtension({
170
+ name: 'textMetrics',
171
+ plugins: () => [
172
+ new Plugin({
173
+ key: new PluginKey('textMetrics'),
174
+ view: (view) => createTextMetricsView(view, options),
175
+ }),
176
+ ],
177
+ });
178
+ function createTextMetricsView(view, { onMetrics, onError }) {
179
+ let typography = readTypography(view.dom);
180
+ let cache = createBlockMeasureCache();
181
+ // One startup read; afterwards the observer reports widths, so resizes
182
+ // and keystrokes alike never force a layout reflow.
183
+ let width = view.dom.clientWidth;
184
+ let destroyed = false;
185
+ const measure = (currentView = view) => {
186
+ let metrics;
187
+ try {
188
+ metrics = measureDoc(currentView.state.doc, width, typography, cache);
189
+ }
190
+ catch (error) {
191
+ onError?.(error);
192
+ return;
193
+ }
194
+ onMetrics(metrics);
195
+ };
196
+ const observer = typeof ResizeObserver === 'undefined'
197
+ ? null
198
+ : new ResizeObserver((entries) => {
199
+ const next = entries[entries.length - 1].contentRect.width;
200
+ if (next === width || next === 0)
201
+ return;
202
+ width = next;
203
+ // Prepared glyph widths survive a resize; only the line arithmetic reruns.
204
+ measure();
205
+ });
206
+ observer?.observe(view.dom);
207
+ // Canvas measurements taken before webfonts finish loading are off;
208
+ // re-prepare everything once the real fonts are in.
209
+ document.fonts?.ready.then(() => {
210
+ if (destroyed)
211
+ return;
212
+ typography = readTypography(view.dom);
213
+ cache = createBlockMeasureCache();
214
+ measure();
215
+ });
216
+ measure();
217
+ return {
218
+ update(currentView, prevState) {
219
+ if (prevState && prevState.doc.eq(currentView.state.doc))
220
+ return;
221
+ measure(currentView);
222
+ },
223
+ destroy() {
224
+ destroyed = true;
225
+ observer?.disconnect();
226
+ },
227
+ };
228
+ }
229
+
230
+ /*
231
+ * Public API Surface of @h-k-dev/angular-email-editor/metrics
232
+ *
233
+ * Its own entry because it is the only part of the library that needs
234
+ * `@chenglou/pretext` — an optional peer: a host without word counts never
235
+ * installs it.
236
+ */
237
+
238
+ /**
239
+ * Generated bundle index. Do not edit.
240
+ */
241
+
242
+ export { createBlockMeasureCache, createTextMetrics, measureDoc, readTypography };
243
+ //# sourceMappingURL=h-k-dev-angular-email-editor-metrics.mjs.map