@myrmidon/cadmus-thesaurus-store 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.
@@ -0,0 +1,1329 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, model, signal, effect, Component, Optional, Inject, input, output, computed, untracked } from '@angular/core';
3
+ import * as i1 from '@angular/forms';
4
+ import { FormBuilder, ReactiveFormsModule, FormControl, Validators, FormGroup, FormsModule } from '@angular/forms';
5
+ import { AsyncPipe } from '@angular/common';
6
+ import { of } from 'rxjs';
7
+ import * as i1$1 from '@angular/material/dialog';
8
+ import { MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog';
9
+ import * as i8 from '@angular/material/progress-bar';
10
+ import { MatProgressBarModule } from '@angular/material/progress-bar';
11
+ import * as i2 from '@angular/material/button';
12
+ import { MatButtonModule, MatIconButton } from '@angular/material/button';
13
+ import * as i3 from '@angular/material/checkbox';
14
+ import { MatCheckboxModule } from '@angular/material/checkbox';
15
+ import * as i5 from '@angular/material/icon';
16
+ import { MatIconModule, MatIcon } from '@angular/material/icon';
17
+ import * as i6$1 from '@angular/material/tooltip';
18
+ import { MatTooltipModule, MatTooltip } from '@angular/material/tooltip';
19
+ import * as i6 from '@angular/material/input';
20
+ import { MatInputModule, MatInput } from '@angular/material/input';
21
+ import * as i4 from '@angular/material/form-field';
22
+ import { MatFormFieldModule, MatFormField } from '@angular/material/form-field';
23
+ import * as i7 from '@angular/material/menu';
24
+ import { MatMenuModule } from '@angular/material/menu';
25
+ import { MatSnackBar } from '@angular/material/snack-bar';
26
+ import { DialogService } from '@myrmidon/ngx-mat-tools';
27
+ import { PagedTreeStore, BrowserTreeNodeComponent, EditablePagedTreeStoreServiceBase, ChangeOperationType } from '@myrmidon/paged-data-browsers';
28
+ import { moveItemInArray, CdkDropList, CdkDrag } from '@angular/cdk/drag-drop';
29
+ import { MatChipListbox, MatChipOption, MatChipRemove } from '@angular/material/chips';
30
+ import { MatExpansionPanel } from '@angular/material/expansion';
31
+
32
+ /**
33
+ * A filter to be used for thesaurus paged tree browsers.
34
+ */
35
+ class ThesaurusPagedTreeFilterComponent {
36
+ constructor() {
37
+ this.dialogRef = inject(MatDialogRef, {
38
+ optional: true,
39
+ });
40
+ this.data = inject(MAT_DIALOG_DATA, { optional: true });
41
+ /**
42
+ * The filter.
43
+ */
44
+ this.filter = model(...(ngDevMode ? [undefined, { debugName: "filter" }] : []));
45
+ this.wrapped = signal(false, ...(ngDevMode ? [{ debugName: "wrapped" }] : []));
46
+ const formBuilder = inject(FormBuilder);
47
+ const data = this.data;
48
+ // form
49
+ this.label = formBuilder.control(null);
50
+ this.form = formBuilder.group({
51
+ label: this.label,
52
+ });
53
+ // bind dialog data if any
54
+ if (this.dialogRef) {
55
+ this.wrapped.set(true);
56
+ if (data) {
57
+ this.filter.set(data.filter);
58
+ }
59
+ }
60
+ else {
61
+ this.wrapped.set(false);
62
+ }
63
+ // update form when filter changes
64
+ effect(() => {
65
+ this.updateForm(this.filter());
66
+ });
67
+ }
68
+ ngOnInit() {
69
+ this.updateForm(this.filter());
70
+ }
71
+ updateForm(filter) {
72
+ if (!filter) {
73
+ this.form.reset();
74
+ return;
75
+ }
76
+ this.label.setValue(filter.label ?? null);
77
+ this.form.markAsPristine();
78
+ }
79
+ getFilter() {
80
+ return {
81
+ label: this.label.value ?? undefined,
82
+ };
83
+ }
84
+ reset() {
85
+ this.form.reset();
86
+ this.filter.set(null);
87
+ this.dialogRef?.close(null);
88
+ }
89
+ apply() {
90
+ const filter = this.getFilter();
91
+ this.filter.set(filter);
92
+ this.dialogRef?.close(filter);
93
+ }
94
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusPagedTreeFilterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
95
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: ThesaurusPagedTreeFilterComponent, isStandalone: true, selector: "cadmus-thesaurus-paged-tree-filter", inputs: { filter: { classPropertyName: "filter", publicName: "filter", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { filter: "filterChange" }, ngImport: i0, template: "<form\r\n [formGroup]=\"form\"\r\n [style.margin.px]=\"wrapped() ? 16 : 0\"\r\n (submit)=\"apply()\"\r\n>\r\n <div class=\"form-row\">\r\n <!-- label -->\r\n <mat-form-field>\r\n <input matInput placeholder=\"label\" [formControl]=\"label\" />\r\n </mat-form-field>\r\n\r\n <!-- buttons -->\r\n <div class=\"button-row\">\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Reset\"\r\n (click)=\"reset()\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Apply\"\r\n [disabled]=\"form.invalid\"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </div>\r\n</form>\r\n", styles: [".form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.button-row{display:flex;align-items:center;flex-wrap:wrap}.button-row *{flex:0 0 auto}.nr{width:6em}fieldset{border:1px solid silver;border-radius:6px;padding:8px 16px;margin:8px 0}legend{color:silver}th{text-align:left;font-weight:400}#tag-selector{width:8em}.tag-chip{border:2px solid transparent;border-radius:6px}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i6$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] }); }
96
+ }
97
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusPagedTreeFilterComponent, decorators: [{
98
+ type: Component,
99
+ args: [{ selector: 'cadmus-thesaurus-paged-tree-filter', imports: [
100
+ ReactiveFormsModule,
101
+ MatButtonModule,
102
+ MatFormFieldModule,
103
+ MatInputModule,
104
+ MatIconModule,
105
+ MatTooltipModule,
106
+ ], template: "<form\r\n [formGroup]=\"form\"\r\n [style.margin.px]=\"wrapped() ? 16 : 0\"\r\n (submit)=\"apply()\"\r\n>\r\n <div class=\"form-row\">\r\n <!-- label -->\r\n <mat-form-field>\r\n <input matInput placeholder=\"label\" [formControl]=\"label\" />\r\n </mat-form-field>\r\n\r\n <!-- buttons -->\r\n <div class=\"button-row\">\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Reset\"\r\n (click)=\"reset()\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Apply\"\r\n [disabled]=\"form.invalid\"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </div>\r\n</form>\r\n", styles: [".form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.button-row{display:flex;align-items:center;flex-wrap:wrap}.button-row *{flex:0 0 auto}.nr{width:6em}fieldset{border:1px solid silver;border-radius:6px;padding:8px 16px;margin:8px 0}legend{color:silver}th{text-align:left;font-weight:400}#tag-selector{width:8em}.tag-chip{border:2px solid transparent;border-radius:6px}\n"] }]
107
+ }], ctorParameters: () => [], propDecorators: { filter: [{ type: i0.Input, args: [{ isSignal: true, alias: "filter", required: false }] }, { type: i0.Output, args: ["filterChange"] }] } });
108
+
109
+ /**
110
+ * A dialog component to edit a thesaurus entry.
111
+ */
112
+ class ThesEntryEditorComponent {
113
+ constructor(dialogRef, data, formBuilder) {
114
+ this.dialogRef = dialogRef;
115
+ this.data = data;
116
+ this.entry = model(...(ngDevMode ? [undefined, { debugName: "entry" }] : []));
117
+ this.wrapped = false;
118
+ // bind to data properties if any
119
+ if (data) {
120
+ this.entry.set(data);
121
+ this.wrapped = true;
122
+ }
123
+ // custom validator for ID existence
124
+ const idExistsValidator = (control) => {
125
+ const entry = this.entry();
126
+ if (!entry || entry.idLocked || !entry.idExists) {
127
+ return null;
128
+ }
129
+ const id = control.value?.trim();
130
+ if (!id) {
131
+ return null;
132
+ }
133
+ // if editing, allow the current id (do not check against itself)
134
+ if (entry.id === id) {
135
+ return null;
136
+ }
137
+ return entry.idExists(id) ? { idExists: true } : null;
138
+ };
139
+ // custom validator for hierarchical ID prefix
140
+ const hierarchicalIdValidator = (control) => {
141
+ const entry = this.entry();
142
+ if (!entry || entry.idLocked || !entry.hierarchical || !entry.parentId) {
143
+ return null;
144
+ }
145
+ const id = control.value?.trim();
146
+ if (!id) {
147
+ return null;
148
+ }
149
+ const prefix = entry.parentId + '.';
150
+ if (!id.startsWith(prefix) || id.length <= prefix.length) {
151
+ return { hierarchicalPrefix: { requiredPrefix: prefix } };
152
+ }
153
+ return null;
154
+ };
155
+ // form controls
156
+ this.id = new FormControl(data?.id || '', {
157
+ nonNullable: true,
158
+ validators: [
159
+ Validators.required,
160
+ Validators.maxLength(300),
161
+ idExistsValidator,
162
+ hierarchicalIdValidator,
163
+ ],
164
+ });
165
+ this.value = new FormControl(data?.value || '', {
166
+ nonNullable: true,
167
+ validators: [Validators.required, Validators.maxLength(500)],
168
+ });
169
+ this.form = formBuilder.group({
170
+ id: this.id,
171
+ value: this.value,
172
+ });
173
+ }
174
+ cancel() {
175
+ this.dialogRef?.close();
176
+ }
177
+ save() {
178
+ if (this.form.invalid) {
179
+ return;
180
+ }
181
+ const old = this.entry();
182
+ // save data and close the dialog
183
+ this.dialogRef?.close({
184
+ id: old.idLocked ? old.id : this.id.value?.trim(),
185
+ value: this.value.value?.trim(),
186
+ });
187
+ }
188
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesEntryEditorComponent, deps: [{ token: i1$1.MatDialogRef, optional: true }, { token: MAT_DIALOG_DATA, optional: true }, { token: i1.FormBuilder }], target: i0.ɵɵFactoryTarget.Component }); }
189
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ThesEntryEditorComponent, isStandalone: true, selector: "cadmus-thesaurus-entry-editor", inputs: { entry: { classPropertyName: "entry", publicName: "entry", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { entry: "entryChange" }, ngImport: i0, template: "<form [formGroup]=\"form\" (submit)=\"save()\" [class.wrapped]=\"wrapped\">\r\n <fieldset>\r\n <legend>entry</legend>\r\n <div class=\"form-row\">\r\n <!-- id -->\r\n @if (entry()!.idLocked) {\r\n <span class=\"readonly\">{{ entry()!.id }}</span>\r\n } @else {\r\n <mat-form-field>\r\n <mat-label>ID</mat-label>\r\n <input matInput [formControl]=\"id\" />\r\n @if ($any(id).errors?.required && (id.dirty || id.touched)) {\r\n <mat-error>ID required</mat-error>\r\n } @if ($any(id).errors?.maxLength && (id.dirty || id.touched)) {\r\n <mat-error>ID too long</mat-error>\r\n } @if ($any(id).errors?.idExists && (id.dirty || id.touched)) {\r\n <mat-error>ID already exists</mat-error>\r\n } @if ($any(id).errors?.requiredPrefix && (id.dirty || id.touched)) {\r\n <mat-error>ID must start with \"{{ entry()!.parentId }}.\"</mat-error>\r\n }\r\n <!-- hint -->\r\n @if (entry()?.parentId) {\r\n <mat-hint>parent: {{ entry()!.parentId }}</mat-hint>\r\n }\r\n </mat-form-field>\r\n }\r\n <!-- value -->\r\n <mat-form-field>\r\n <mat-label>value</mat-label>\r\n <input matInput [formControl]=\"value\" />\r\n @if ($any(value).errors?.required && (value.dirty || value.touched)) {\r\n <mat-error>value required</mat-error>\r\n } @if ($any(value).errors?.maxLength && (value.dirty || value.touched))\r\n {\r\n <mat-error>value too long</mat-error>\r\n }\r\n </mat-form-field>\r\n </div>\r\n <!-- buttons -->\r\n <div>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Discard changes\"\r\n (click)=\"cancel()\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Accept changes\"\r\n [disabled]=\"form.invalid || form.pristine\"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </fieldset>\r\n</form>\r\n", styles: [".form-row{display:flex;gap:8px;align-items:center}.form-row *{flex:0 0 auto}.readonly{color:silver}.wrapped{padding:16px}fieldset{border:1px solid silver;border-radius:6px;padding:6px}legend{color:silver}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i6$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] }); }
190
+ }
191
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesEntryEditorComponent, decorators: [{
192
+ type: Component,
193
+ args: [{ selector: 'cadmus-thesaurus-entry-editor', imports: [
194
+ ReactiveFormsModule,
195
+ MatButtonModule,
196
+ MatFormFieldModule,
197
+ MatIconModule,
198
+ MatInputModule,
199
+ MatTooltipModule,
200
+ ], template: "<form [formGroup]=\"form\" (submit)=\"save()\" [class.wrapped]=\"wrapped\">\r\n <fieldset>\r\n <legend>entry</legend>\r\n <div class=\"form-row\">\r\n <!-- id -->\r\n @if (entry()!.idLocked) {\r\n <span class=\"readonly\">{{ entry()!.id }}</span>\r\n } @else {\r\n <mat-form-field>\r\n <mat-label>ID</mat-label>\r\n <input matInput [formControl]=\"id\" />\r\n @if ($any(id).errors?.required && (id.dirty || id.touched)) {\r\n <mat-error>ID required</mat-error>\r\n } @if ($any(id).errors?.maxLength && (id.dirty || id.touched)) {\r\n <mat-error>ID too long</mat-error>\r\n } @if ($any(id).errors?.idExists && (id.dirty || id.touched)) {\r\n <mat-error>ID already exists</mat-error>\r\n } @if ($any(id).errors?.requiredPrefix && (id.dirty || id.touched)) {\r\n <mat-error>ID must start with \"{{ entry()!.parentId }}.\"</mat-error>\r\n }\r\n <!-- hint -->\r\n @if (entry()?.parentId) {\r\n <mat-hint>parent: {{ entry()!.parentId }}</mat-hint>\r\n }\r\n </mat-form-field>\r\n }\r\n <!-- value -->\r\n <mat-form-field>\r\n <mat-label>value</mat-label>\r\n <input matInput [formControl]=\"value\" />\r\n @if ($any(value).errors?.required && (value.dirty || value.touched)) {\r\n <mat-error>value required</mat-error>\r\n } @if ($any(value).errors?.maxLength && (value.dirty || value.touched))\r\n {\r\n <mat-error>value too long</mat-error>\r\n }\r\n </mat-form-field>\r\n </div>\r\n <!-- buttons -->\r\n <div>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Discard changes\"\r\n (click)=\"cancel()\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Accept changes\"\r\n [disabled]=\"form.invalid || form.pristine\"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </fieldset>\r\n</form>\r\n", styles: [".form-row{display:flex;gap:8px;align-items:center}.form-row *{flex:0 0 auto}.readonly{color:silver}.wrapped{padding:16px}fieldset{border:1px solid silver;border-radius:6px;padding:6px}legend{color:silver}\n"] }]
201
+ }], ctorParameters: () => [{ type: i1$1.MatDialogRef, decorators: [{
202
+ type: Optional
203
+ }] }, { type: undefined, decorators: [{
204
+ type: Optional
205
+ }, {
206
+ type: Inject,
207
+ args: [MAT_DIALOG_DATA]
208
+ }] }, { type: i1.FormBuilder }], propDecorators: { entry: [{ type: i0.Input, args: [{ isSignal: true, alias: "entry", required: false }] }, { type: i0.Output, args: ["entryChange"] }] } });
209
+
210
+ /**
211
+ * An editable paged tree browser for thesaurus entries. Editing happens in memory
212
+ * and when complete, the changes can be committed to the underlying data store
213
+ * via the EditableStaticThesaurusPagedTreeStoreService.
214
+ */
215
+ class EditableThesaurusBrowserComponent {
216
+ constructor() {
217
+ this._dialog = inject(MatDialog);
218
+ this._snackBar = inject(MatSnackBar);
219
+ this._dialogService = inject(DialogService);
220
+ /**
221
+ * The editable service to use to load the nodes.
222
+ */
223
+ this.service = input(...(ngDevMode ? [undefined, { debugName: "service" }] : []));
224
+ /**
225
+ * True to show the tree root nodes filter.
226
+ */
227
+ this.hasRootFilter = input(false, ...(ngDevMode ? [{ debugName: "hasRootFilter" }] : []));
228
+ /**
229
+ * Emitted when a node is clicked.
230
+ */
231
+ this.nodePick = output();
232
+ /**
233
+ * Emitted when the changes state changes.
234
+ */
235
+ this.changesStateChange = output();
236
+ /**
237
+ * The store instance, built from the service.
238
+ */
239
+ this.store = computed(() => {
240
+ const service = this.service();
241
+ if (!service) {
242
+ return null;
243
+ }
244
+ const store = new PagedTreeStore(service);
245
+ return store;
246
+ }, ...(ngDevMode ? [{ debugName: "store" }] : []));
247
+ this.loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
248
+ this.selectedNode = signal(null, ...(ngDevMode ? [{ debugName: "selectedNode" }] : []));
249
+ this.debug = new FormControl(false, {
250
+ nonNullable: true,
251
+ });
252
+ this.hideLoc = new FormControl(false, {
253
+ nonNullable: true,
254
+ });
255
+ this.hideFilter = new FormControl(false, {
256
+ nonNullable: true,
257
+ });
258
+ this.filter$ = of({});
259
+ this.nodes$ = of([]);
260
+ this.label = new FormControl(null);
261
+ this.form = new FormGroup({
262
+ label: this.label,
263
+ });
264
+ }
265
+ ngOnInit() {
266
+ const store = this.store();
267
+ if (!store) {
268
+ console.error('Store not available - service input is required');
269
+ return;
270
+ }
271
+ // setup observables (delayed because we require store to be available)
272
+ this.nodes$ = store.nodes$;
273
+ this.filter$ = store.filter$;
274
+ if (!store.getNodes().length) {
275
+ this.loading.set(true);
276
+ store.setFilter({}).finally(() => {
277
+ this.loading.set(false);
278
+ console.log('nodes loaded', store.getNodes());
279
+ });
280
+ }
281
+ // subscribe to changes state
282
+ const service = this.service();
283
+ if (service) {
284
+ this._sub = service.hasChanges$.subscribe((hasChanges) => {
285
+ this.changesStateChange.emit(hasChanges);
286
+ });
287
+ }
288
+ }
289
+ ngOnDestroy() {
290
+ this._sub?.unsubscribe();
291
+ }
292
+ reset() {
293
+ const store = this.store();
294
+ if (!store)
295
+ return;
296
+ this.loading.set(true);
297
+ store.reset().finally(() => {
298
+ this.loading.set(false);
299
+ });
300
+ }
301
+ onToggleExpanded(node) {
302
+ const store = this.store();
303
+ if (!store)
304
+ return;
305
+ this.loading.set(true);
306
+ if (node.expanded) {
307
+ store.collapse(node.id).finally(() => {
308
+ this.loading.set(false);
309
+ });
310
+ }
311
+ else {
312
+ store.expand(node.id).finally(() => {
313
+ this.loading.set(false);
314
+ });
315
+ }
316
+ }
317
+ onPageChangeRequest(request) {
318
+ const store = this.store();
319
+ if (!store)
320
+ return;
321
+ this.loading.set(true);
322
+ store.changePage(request.node.id, request.paging.pageNumber).finally(() => {
323
+ this.loading.set(false);
324
+ });
325
+ }
326
+ onFilterChange(filter) {
327
+ const store = this.store();
328
+ if (!store)
329
+ return;
330
+ console.log('filter change', filter);
331
+ this.loading.set(true);
332
+ store.setFilter(filter || {}).finally(() => {
333
+ this.loading.set(false);
334
+ });
335
+ }
336
+ onEditFilterRequest(node) {
337
+ const store = this.store();
338
+ if (!store)
339
+ return;
340
+ const dialogRef = this._dialog.open(ThesaurusPagedTreeFilterComponent, {
341
+ data: {
342
+ filter: node.filter,
343
+ },
344
+ });
345
+ dialogRef.afterClosed().subscribe((filter) => {
346
+ if (filter === null) {
347
+ store.setNodeFilter(node.id, null);
348
+ }
349
+ else if (filter) {
350
+ store.setNodeFilter(node.id, filter);
351
+ }
352
+ });
353
+ }
354
+ expandAll() {
355
+ const store = this.store();
356
+ if (!store)
357
+ return;
358
+ store.expandAll();
359
+ }
360
+ collapseAll() {
361
+ const store = this.store();
362
+ if (!store)
363
+ return;
364
+ store.collapseAll();
365
+ }
366
+ clear() {
367
+ const store = this.store();
368
+ if (!store)
369
+ return;
370
+ store.clear();
371
+ }
372
+ onNodeClick(node) {
373
+ this.selectedNode.set(node);
374
+ if (!node.hasChildren) {
375
+ this.nodePick.emit(node);
376
+ }
377
+ }
378
+ findLabels() {
379
+ const store = this.store();
380
+ if (!store || !this.label.value)
381
+ return;
382
+ store.findLabels(this.label.value);
383
+ }
384
+ removeHilites() {
385
+ const store = this.store();
386
+ if (!store)
387
+ return;
388
+ store.removeHilites();
389
+ }
390
+ // editing operations
391
+ editEntry(entry) {
392
+ return new Promise((resolve) => {
393
+ const dialogRef = this._dialog.open(ThesEntryEditorComponent, {
394
+ data: entry,
395
+ });
396
+ dialogRef.afterClosed().subscribe((result) => {
397
+ resolve(result);
398
+ });
399
+ });
400
+ }
401
+ async addChild() {
402
+ const store = this.store();
403
+ const selected = this.selectedNode();
404
+ if (!store || !selected) {
405
+ this._snackBar.open('Please select a node first', 'Close', {
406
+ duration: 2000,
407
+ });
408
+ return;
409
+ }
410
+ // get the current entries to check for duplicates
411
+ const currentService = this.service();
412
+ if (!currentService)
413
+ return;
414
+ // determine the parent ID for the new child
415
+ const parentKey = selected.key; // original entry ID
416
+ // edit the new entry
417
+ const newEntry = await this.editEntry({
418
+ id: '',
419
+ value: '',
420
+ hierarchical: true,
421
+ parentId: parentKey,
422
+ idExists: (id) => currentService.getCurrentEntries().some((e) => e.id === id),
423
+ });
424
+ if (!newEntry) {
425
+ return;
426
+ }
427
+ store
428
+ .addChild(selected.id, {
429
+ label: newEntry.value,
430
+ y: 0, // will be calculated by the store
431
+ x: 0, // will be calculated by the store
432
+ tag: selected.tag,
433
+ })
434
+ .then((result) => {
435
+ if (result) {
436
+ // update the temporary node with the user-provided ID,
437
+ // this will be handled in the service's nodeToEntry method
438
+ result._userProvidedId = newEntry.id;
439
+ this._snackBar.open('Child added successfully', 'Close', {
440
+ duration: 2000,
441
+ });
442
+ }
443
+ });
444
+ }
445
+ async addSibling() {
446
+ const store = this.store();
447
+ const selected = this.selectedNode();
448
+ if (!store || !selected) {
449
+ this._snackBar.open('Please select a node first', 'Close', {
450
+ duration: 2000,
451
+ });
452
+ return;
453
+ }
454
+ // get the current entries to check for duplicates
455
+ const currentService = this.service();
456
+ if (!currentService)
457
+ return;
458
+ const currentEntries = currentService.getCurrentEntries();
459
+ const selectedKey = selected.key; // original entry ID
460
+ // determine the parent ID for the new sibling
461
+ const selectedParts = selectedKey.split('.');
462
+ const parentKey = selectedParts.length > 1 ? selectedParts.slice(0, -1).join('.') : '';
463
+ const newEntry = await this.editEntry({
464
+ id: '',
465
+ value: '',
466
+ hierarchical: true,
467
+ parentId: parentKey,
468
+ idExists: (id) => currentService.getCurrentEntries().some((e) => e.id === id),
469
+ });
470
+ if (!newEntry) {
471
+ return;
472
+ }
473
+ // check that the new ID has the same depth
474
+ if (newEntry.id.split('.').length !== selectedParts.length) {
475
+ this._snackBar.open(`Sibling must have same depth as "${selectedKey}"`, 'Close', { duration: 3000 });
476
+ return;
477
+ }
478
+ store
479
+ .addSibling(selected.id, {
480
+ label: newEntry.value,
481
+ tag: selected.tag,
482
+ })
483
+ .then((result) => {
484
+ if (result) {
485
+ // update the temporary node with the user-provided ID
486
+ result._userProvidedId = newEntry.id;
487
+ this._snackBar.open('Sibling added successfully', 'Close', {
488
+ duration: 2000,
489
+ });
490
+ }
491
+ });
492
+ }
493
+ removeNode() {
494
+ const store = this.store();
495
+ const selected = this.selectedNode();
496
+ if (!store || !selected) {
497
+ this._snackBar.open('Please select a node first', 'Close', {
498
+ duration: 2000,
499
+ });
500
+ return;
501
+ }
502
+ const selectedKey = selected.key || `temp_${selected.id}`;
503
+ this._dialogService
504
+ .confirm('Confirm Deletion', `Delete "${selectedKey}" (${selected.label}) and all its descendants?`)
505
+ .subscribe((result) => {
506
+ if (!result) {
507
+ return;
508
+ }
509
+ store.removeNode(selected.id).then((result) => {
510
+ if (result) {
511
+ this.selectedNode.set(null);
512
+ this._snackBar.open('Node removed successfully', 'Close', {
513
+ duration: 2000,
514
+ });
515
+ }
516
+ });
517
+ });
518
+ }
519
+ async editNode() {
520
+ const currentService = this.service();
521
+ if (!currentService)
522
+ return;
523
+ const store = this.store();
524
+ const selected = this.selectedNode();
525
+ if (!store || !selected) {
526
+ this._snackBar.open('Please select a node first', 'Close', {
527
+ duration: 2000,
528
+ });
529
+ return;
530
+ }
531
+ const selectedKey = selected.key;
532
+ const isNewNode = selected.id < 0; // temporary ID means it's a new node
533
+ const newEntry = await this.editEntry({
534
+ id: selectedKey || '',
535
+ value: selected.label,
536
+ idLocked: !isNewNode,
537
+ idExists: (id) => currentService.getCurrentEntries().some((entry) => entry.id === id),
538
+ });
539
+ if (!newEntry)
540
+ return;
541
+ store
542
+ .replaceNode(selected.id, {
543
+ label: newEntry.value,
544
+ tag: selected.tag,
545
+ })
546
+ .then((result) => {
547
+ if (result) {
548
+ // update the temporary node with the user-provided ID if it's a new node
549
+ if (isNewNode) {
550
+ result._userProvidedId = newEntry.id;
551
+ }
552
+ this._snackBar.open('Node updated successfully', 'Close', {
553
+ duration: 2000,
554
+ });
555
+ }
556
+ });
557
+ }
558
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: EditableThesaurusBrowserComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
559
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: EditableThesaurusBrowserComponent, isStandalone: true, selector: "cadmus-editable-thesaurus-browser", inputs: { service: { classPropertyName: "service", publicName: "service", isSignal: true, isRequired: false, transformFunction: null }, hasRootFilter: { classPropertyName: "hasRootFilter", publicName: "hasRootFilter", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { nodePick: "nodePick", changesStateChange: "changesStateChange" }, ngImport: i0, template: "<div id=\"container\">\r\n <!-- filters -->\r\n <div id=\"filters\" class=\"form-row\">\r\n <form [formGroup]=\"form\" (submit)=\"findLabels()\">\r\n <fieldset>\r\n <legend>finder</legend>\r\n <div class=\"form-row\">\r\n <mat-form-field>\r\n <mat-label>label</mat-label>\r\n <input matInput [formControl]=\"label\" />\r\n @if ($any(label).errors?.required && (label.dirty || label.touched))\r\n {\r\n <mat-error>label required</mat-error>\r\n }\r\n </mat-form-field>\r\n <div class=\"form-row\" style=\"gap: 0\">\r\n <button type=\"submit\" mat-icon-button class=\"mat-primary\">\r\n <mat-icon>search</mat-icon>\r\n </button>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n (click)=\"removeHilites()\"\r\n class=\"mat-warn\"\r\n >\r\n <mat-icon>clear</mat-icon>\r\n </button>\r\n </div>\r\n </div>\r\n </fieldset>\r\n </form>\r\n @if (hasRootFilter()) {\r\n <fieldset>\r\n <legend>global filter</legend>\r\n <cadmus-thesaurus-paged-tree-filter\r\n [filter]=\"filter$ | async\"\r\n (filterChange)=\"onFilterChange($event)\"\r\n />\r\n </fieldset>\r\n }\r\n </div>\r\n\r\n <!-- editing actions -->\r\n <div class=\"editing-actions\">\r\n <button\r\n type=\"button\"\r\n mat-raised-button\r\n color=\"primary\"\r\n [disabled]=\"!selectedNode()\"\r\n [matMenuTriggerFor]=\"addMenu\"\r\n >\r\n <mat-icon>add</mat-icon>\r\n add\r\n </button>\r\n\r\n <mat-menu #addMenu=\"matMenu\">\r\n <button type=\"button\" mat-menu-item (click)=\"addChild()\">\r\n <mat-icon>subdirectory_arrow_right</mat-icon>\r\n add child\r\n </button>\r\n <button type=\"button\" mat-menu-item (click)=\"addSibling()\">\r\n <mat-icon>arrow_right_alt</mat-icon>\r\n add sibling\r\n </button>\r\n </mat-menu>\r\n\r\n <button\r\n type=\"button\"\r\n mat-stroked-button\r\n color=\"accent\"\r\n [disabled]=\"!selectedNode()\"\r\n (click)=\"editNode()\"\r\n >\r\n <mat-icon>edit</mat-icon>\r\n edit\r\n </button>\r\n\r\n <button\r\n type=\"button\"\r\n mat-stroked-button\r\n color=\"warn\"\r\n [disabled]=\"!selectedNode()\"\r\n (click)=\"removeNode()\"\r\n >\r\n <mat-icon>delete</mat-icon>\r\n remove\r\n </button>\r\n\r\n @if (selectedNode()) {\r\n <span class=\"selected-info\"> selected: {{ selectedNode()!.label }} </span>\r\n }\r\n </div>\r\n\r\n <!-- tree -->\r\n <div id=\"tree\">\r\n <!-- progress -->\r\n @if (loading()) {\r\n <div>\r\n <mat-progress-bar mode=\"indeterminate\"></mat-progress-bar>\r\n </div>\r\n }\r\n <!-- nodes -->\r\n @if (nodes$ | async; as nodes) {\r\n <div>\r\n @for (node of nodes; track node.id; let i = $index) {\r\n <div\r\n class=\"button-row\"\r\n [class.hilite]=\"node.hilite\"\r\n [class.selected]=\"selectedNode()?.id === node.id\"\r\n (click)=\"onNodeClick(node)\"\r\n >\r\n <pdb-browser-tree-node\r\n [node]=\"node\"\r\n [debug]=\"debug.value\"\r\n [paging]=\"\r\n node.expanded &&\r\n i + 1 < nodes.length &&\r\n nodes[i + 1].paging.pageCount > 1\r\n ? nodes[i + 1].paging\r\n : undefined\r\n \"\r\n [hideFilter]=\"hideFilter.value\"\r\n [hideLoc]=\"hideLoc.value\"\r\n (toggleExpandedRequest)=\"onToggleExpanded($any($event))\"\r\n (changePageRequest)=\"onPageChangeRequest($event)\"\r\n (editNodeFilterRequest)=\"onEditFilterRequest($any($event))\"\r\n />\r\n </div>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-row\">\r\n <mat-checkbox [formControl]=\"debug\">debug</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideFilter\">no filter</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideLoc\">no loc.</mat-checkbox>\r\n <button\r\n type=\"button\"\r\n mat-flat-button\r\n (click)=\"collapseAll()\"\r\n style=\"margin-left: 24px\"\r\n >\r\n collapse\r\n </button>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: ["#container{padding:16px}.form-row{display:flex;align-items:center;gap:8px;margin-bottom:16px}.editing-actions{display:flex;align-items:center;gap:12px;margin-bottom:16px;padding:12px;border:1px solid #e0e0e0;border-radius:4px;background-color:#fafafa}.selected-info{margin-left:auto;font-style:italic;color:#666}.button-row{cursor:pointer;padding:4px;border-radius:4px;transition:background-color .2s}.button-row:hover{background-color:#f5f5f5}.button-row.selected{background-color:#e3f2fd;border:1px solid #2196f3}.button-row.hilite{background-color:#fff3e0}#tree{overflow-y:auto}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i7.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i7.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i7.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatProgressBarModule }, { kind: "component", type: i8.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "component", type: ThesaurusPagedTreeFilterComponent, selector: "cadmus-thesaurus-paged-tree-filter", inputs: ["filter"], outputs: ["filterChange"] }, { kind: "component", type: BrowserTreeNodeComponent, selector: "pdb-browser-tree-node", inputs: ["node", "paging", "debug", "hideLabel", "hideLoc", "hidePaging", "hideFilter", "indentSize", "rangeWidth"], outputs: ["toggleExpandedRequest", "changePageRequest", "editNodeFilterRequest"] }, { kind: "pipe", type: AsyncPipe, name: "async" }] }); }
560
+ }
561
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: EditableThesaurusBrowserComponent, decorators: [{
562
+ type: Component,
563
+ args: [{ selector: 'cadmus-editable-thesaurus-browser', imports: [
564
+ AsyncPipe,
565
+ ReactiveFormsModule,
566
+ MatButtonModule,
567
+ MatCheckboxModule,
568
+ MatFormFieldModule,
569
+ MatIconModule,
570
+ MatInputModule,
571
+ MatMenuModule,
572
+ MatProgressBarModule,
573
+ MatTooltipModule,
574
+ ThesaurusPagedTreeFilterComponent,
575
+ BrowserTreeNodeComponent,
576
+ ], template: "<div id=\"container\">\r\n <!-- filters -->\r\n <div id=\"filters\" class=\"form-row\">\r\n <form [formGroup]=\"form\" (submit)=\"findLabels()\">\r\n <fieldset>\r\n <legend>finder</legend>\r\n <div class=\"form-row\">\r\n <mat-form-field>\r\n <mat-label>label</mat-label>\r\n <input matInput [formControl]=\"label\" />\r\n @if ($any(label).errors?.required && (label.dirty || label.touched))\r\n {\r\n <mat-error>label required</mat-error>\r\n }\r\n </mat-form-field>\r\n <div class=\"form-row\" style=\"gap: 0\">\r\n <button type=\"submit\" mat-icon-button class=\"mat-primary\">\r\n <mat-icon>search</mat-icon>\r\n </button>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n (click)=\"removeHilites()\"\r\n class=\"mat-warn\"\r\n >\r\n <mat-icon>clear</mat-icon>\r\n </button>\r\n </div>\r\n </div>\r\n </fieldset>\r\n </form>\r\n @if (hasRootFilter()) {\r\n <fieldset>\r\n <legend>global filter</legend>\r\n <cadmus-thesaurus-paged-tree-filter\r\n [filter]=\"filter$ | async\"\r\n (filterChange)=\"onFilterChange($event)\"\r\n />\r\n </fieldset>\r\n }\r\n </div>\r\n\r\n <!-- editing actions -->\r\n <div class=\"editing-actions\">\r\n <button\r\n type=\"button\"\r\n mat-raised-button\r\n color=\"primary\"\r\n [disabled]=\"!selectedNode()\"\r\n [matMenuTriggerFor]=\"addMenu\"\r\n >\r\n <mat-icon>add</mat-icon>\r\n add\r\n </button>\r\n\r\n <mat-menu #addMenu=\"matMenu\">\r\n <button type=\"button\" mat-menu-item (click)=\"addChild()\">\r\n <mat-icon>subdirectory_arrow_right</mat-icon>\r\n add child\r\n </button>\r\n <button type=\"button\" mat-menu-item (click)=\"addSibling()\">\r\n <mat-icon>arrow_right_alt</mat-icon>\r\n add sibling\r\n </button>\r\n </mat-menu>\r\n\r\n <button\r\n type=\"button\"\r\n mat-stroked-button\r\n color=\"accent\"\r\n [disabled]=\"!selectedNode()\"\r\n (click)=\"editNode()\"\r\n >\r\n <mat-icon>edit</mat-icon>\r\n edit\r\n </button>\r\n\r\n <button\r\n type=\"button\"\r\n mat-stroked-button\r\n color=\"warn\"\r\n [disabled]=\"!selectedNode()\"\r\n (click)=\"removeNode()\"\r\n >\r\n <mat-icon>delete</mat-icon>\r\n remove\r\n </button>\r\n\r\n @if (selectedNode()) {\r\n <span class=\"selected-info\"> selected: {{ selectedNode()!.label }} </span>\r\n }\r\n </div>\r\n\r\n <!-- tree -->\r\n <div id=\"tree\">\r\n <!-- progress -->\r\n @if (loading()) {\r\n <div>\r\n <mat-progress-bar mode=\"indeterminate\"></mat-progress-bar>\r\n </div>\r\n }\r\n <!-- nodes -->\r\n @if (nodes$ | async; as nodes) {\r\n <div>\r\n @for (node of nodes; track node.id; let i = $index) {\r\n <div\r\n class=\"button-row\"\r\n [class.hilite]=\"node.hilite\"\r\n [class.selected]=\"selectedNode()?.id === node.id\"\r\n (click)=\"onNodeClick(node)\"\r\n >\r\n <pdb-browser-tree-node\r\n [node]=\"node\"\r\n [debug]=\"debug.value\"\r\n [paging]=\"\r\n node.expanded &&\r\n i + 1 < nodes.length &&\r\n nodes[i + 1].paging.pageCount > 1\r\n ? nodes[i + 1].paging\r\n : undefined\r\n \"\r\n [hideFilter]=\"hideFilter.value\"\r\n [hideLoc]=\"hideLoc.value\"\r\n (toggleExpandedRequest)=\"onToggleExpanded($any($event))\"\r\n (changePageRequest)=\"onPageChangeRequest($event)\"\r\n (editNodeFilterRequest)=\"onEditFilterRequest($any($event))\"\r\n />\r\n </div>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-row\">\r\n <mat-checkbox [formControl]=\"debug\">debug</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideFilter\">no filter</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideLoc\">no loc.</mat-checkbox>\r\n <button\r\n type=\"button\"\r\n mat-flat-button\r\n (click)=\"collapseAll()\"\r\n style=\"margin-left: 24px\"\r\n >\r\n collapse\r\n </button>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: ["#container{padding:16px}.form-row{display:flex;align-items:center;gap:8px;margin-bottom:16px}.editing-actions{display:flex;align-items:center;gap:12px;margin-bottom:16px;padding:12px;border:1px solid #e0e0e0;border-radius:4px;background-color:#fafafa}.selected-info{margin-left:auto;font-style:italic;color:#666}.button-row{cursor:pointer;padding:4px;border-radius:4px;transition:background-color .2s}.button-row:hover{background-color:#f5f5f5}.button-row.selected{background-color:#e3f2fd;border:1px solid #2196f3}.button-row.hilite{background-color:#fff3e0}#tree{overflow-y:auto}\n"] }]
577
+ }], propDecorators: { service: [{ type: i0.Input, args: [{ isSignal: true, alias: "service", required: false }] }], hasRootFilter: [{ type: i0.Input, args: [{ isSignal: true, alias: "hasRootFilter", required: false }] }], nodePick: [{ type: i0.Output, args: ["nodePick"] }], changesStateChange: [{ type: i0.Output, args: ["changesStateChange"] }] } });
578
+
579
+ /**
580
+ * A label rendering function which removes from a label
581
+ * all the characters past the last colon, trimming the result.
582
+ * This is a typical rendering when dealing with hierarchical
583
+ * thesaurus entries, e.g. "furniture: table: color", where
584
+ * we can shorten the label to just "color", as "furniture"
585
+ * and "table" are its ancestors.
586
+ */
587
+ function renderLabelFromLastColon(label) {
588
+ if (!label) {
589
+ return label;
590
+ }
591
+ const i = label.lastIndexOf(':');
592
+ return i > -1 && i + 1 < label.length ? label.substring(i + 1).trim() : label;
593
+ }
594
+ /**
595
+ * A static paged tree store service for thesaurus entries.
596
+ * This builds tree nodes from all the thesaurus entries from a specific
597
+ * thesaurus, assuming that entry IDs are hierarchical and
598
+ * separated by dots (.).
599
+ * Nodes are received all at once for a thesaurus, so this is essentially
600
+ * an adapter to the paged tree store service interface.
601
+ * The entries are a flat list where hierarchy is implied by the IDs:
602
+ * for instance, "animal.mammal.dog" is a child of "animal.mammal"
603
+ * which is a child of "animal". Numeric IDs are added to each node
604
+ * based on the order of entries to ensure stable IDs.
605
+ * The label of each node can be rendered as-is from the entry value,
606
+ * or a custom rendering function can be provided to the constructor.
607
+ */
608
+ class StaticThesaurusPagedTreeStoreService {
609
+ /**
610
+ * Constructor.
611
+ * @param entries The thesaurus entries.
612
+ * @param _renderLabel An optional function to render the label
613
+ * of each node.
614
+ */
615
+ constructor(entries, _renderLabel) {
616
+ this._renderLabel = _renderLabel;
617
+ this._nodes = [];
618
+ this._built = false;
619
+ // assign a number to each entry for stable IDs
620
+ this._entries = entries.map((entry, index) => ({
621
+ ...entry,
622
+ n: index + 1,
623
+ }));
624
+ }
625
+ /**
626
+ * Ensure that nodes have been built from entries.
627
+ */
628
+ ensureNodes() {
629
+ // lazily build the nodes only once
630
+ if (this._built) {
631
+ return;
632
+ }
633
+ // create a map of sibling numbers (x) where:
634
+ // - key=node.id (0 is used for the virtual root);
635
+ // - value=max x value for that parent.
636
+ const xMap = new Map();
637
+ // build nodes from entries
638
+ for (const entry of this._entries) {
639
+ const hasDot = entry.id.includes('.');
640
+ const keyParts = entry.id.split('.');
641
+ // create the node
642
+ const node = {
643
+ id: entry.n,
644
+ label: hasDot && this._renderLabel
645
+ ? this._renderLabel(entry.value)
646
+ : entry.value,
647
+ key: entry.id,
648
+ value: entry.value,
649
+ paging: { pageNumber: 0, pageCount: 0, total: 0 },
650
+ y: hasDot ? keyParts.length : 1,
651
+ x: 0, // will be set later
652
+ parentId: undefined, // will be set later
653
+ hasChildren: false, // will be set later
654
+ };
655
+ // if it has a dot, find its parent and set parentId
656
+ if (hasDot) {
657
+ // build the parent key
658
+ const parentKey = keyParts.slice(0, keyParts.length - 1).join('.');
659
+ // find the parent node with that key
660
+ const parentNode = this._nodes.find((n) => n.key === parentKey);
661
+ // if found, set parentId and x in child and hasChildren in parent
662
+ if (parentNode) {
663
+ node.parentId = parentNode.id;
664
+ parentNode.hasChildren = true;
665
+ if (xMap.has(parentNode.id)) {
666
+ xMap.set(parentNode.id, xMap.get(parentNode.id) + 1);
667
+ }
668
+ else {
669
+ xMap.set(parentNode.id, 1);
670
+ }
671
+ node.x = xMap.get(parentNode.id);
672
+ }
673
+ else {
674
+ // parent not found, treat as root
675
+ if (xMap.has(0)) {
676
+ xMap.set(0, xMap.get(0) + 1);
677
+ }
678
+ else {
679
+ xMap.set(0, 1);
680
+ }
681
+ node.x = xMap.get(0);
682
+ }
683
+ }
684
+ else {
685
+ // else it's a root node
686
+ if (xMap.has(0)) {
687
+ xMap.set(0, xMap.get(0) + 1);
688
+ }
689
+ else {
690
+ xMap.set(0, 1);
691
+ }
692
+ node.x = xMap.get(0);
693
+ }
694
+ this._nodes.push(node);
695
+ }
696
+ this._built = true;
697
+ }
698
+ /**
699
+ * Get the specified page of nodes.
700
+ * @param filter The filter.
701
+ * @param pageNumber The page number.
702
+ * @param pageSize The page size.
703
+ * @param hasMockRoot Whether the root node is a mock node. Not used here.
704
+ */
705
+ getNodes(filter, pageNumber, pageSize, hasMockRoot) {
706
+ this.ensureNodes();
707
+ // apply filtering
708
+ let nodes = this._nodes.filter((n) => {
709
+ // filter by parentId (required by tree structure)
710
+ if (filter.parentId !== undefined && filter.parentId !== null) {
711
+ if (n.parentId !== filter.parentId) {
712
+ return false;
713
+ }
714
+ }
715
+ else {
716
+ if (n.parentId) {
717
+ return false;
718
+ }
719
+ }
720
+ // filter by label
721
+ if (filter.label) {
722
+ const filterValue = filter.label.toLowerCase();
723
+ if (!n.label.toLowerCase().includes(filterValue)) {
724
+ return false;
725
+ }
726
+ }
727
+ return true;
728
+ });
729
+ // page and return
730
+ const paged = nodes.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
731
+ return of({
732
+ items: paged,
733
+ pageNumber: pageNumber,
734
+ pageSize: pageSize,
735
+ pageCount: Math.ceil(nodes.length / pageSize),
736
+ total: nodes.length,
737
+ });
738
+ }
739
+ }
740
+
741
+ /**
742
+ * A readonly paged tree browser for thesaurus entries. This is a lower-level component
743
+ * used as an adapter between the generic paged tree browser and the thesaurus entries
744
+ * data model.
745
+ */
746
+ class ThesaurusBrowserComponent {
747
+ constructor() {
748
+ this._dialog = inject(MatDialog);
749
+ /**
750
+ * The service to use to load the nodes.
751
+ */
752
+ this.service = input(new StaticThesaurusPagedTreeStoreService([], renderLabelFromLastColon), ...(ngDevMode ? [{ debugName: "service" }] : []));
753
+ /**
754
+ * Emitted when a node is clicked.
755
+ */
756
+ this.nodePick = output();
757
+ /**
758
+ * The store instance, built from the service.
759
+ */
760
+ this.store = computed(() => {
761
+ const service = this.service();
762
+ const store = new PagedTreeStore(service);
763
+ this.nodes$ = store.nodes$;
764
+ this.filter$ = store.filter$;
765
+ return store;
766
+ }, ...(ngDevMode ? [{ debugName: "store" }] : []));
767
+ this.loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
768
+ this.debug = new FormControl(false, {
769
+ nonNullable: true,
770
+ });
771
+ this.hideLoc = new FormControl(false, {
772
+ nonNullable: true,
773
+ });
774
+ this.hideFilter = new FormControl(false, {
775
+ nonNullable: true,
776
+ });
777
+ this.label = new FormControl(null);
778
+ this.form = new FormGroup({
779
+ label: this.label,
780
+ });
781
+ const store = this.store();
782
+ this.nodes$ = store.nodes$;
783
+ this.filter$ = store.filter$;
784
+ }
785
+ ngOnInit() {
786
+ if (!this.store().getNodes().length) {
787
+ this.loading.set(true);
788
+ this.store()
789
+ .setFilter({})
790
+ .finally(() => {
791
+ this.loading.set(false);
792
+ console.log('nodes loaded', this.store().getNodes());
793
+ });
794
+ }
795
+ }
796
+ ngOnDestroy() {
797
+ this._sub?.unsubscribe();
798
+ }
799
+ reset() {
800
+ this.loading.set(true);
801
+ this.store()
802
+ .reset()
803
+ .finally(() => {
804
+ this.loading.set(false);
805
+ });
806
+ }
807
+ onToggleExpanded(node) {
808
+ this.loading.set(true);
809
+ if (node.expanded) {
810
+ this.store()
811
+ .collapse(node.id)
812
+ .finally(() => {
813
+ this.loading.set(false);
814
+ });
815
+ }
816
+ else {
817
+ this.store()
818
+ .expand(node.id)
819
+ .finally(() => {
820
+ this.loading.set(false);
821
+ });
822
+ }
823
+ }
824
+ onPageChangeRequest(request) {
825
+ this.loading.set(true);
826
+ this.store()
827
+ .changePage(request.node.id, request.paging.pageNumber)
828
+ .finally(() => {
829
+ this.loading.set(false);
830
+ });
831
+ }
832
+ onFilterChange(filter) {
833
+ console.log('filter change', filter);
834
+ this.loading.set(true);
835
+ this.store()
836
+ .setFilter(filter || {})
837
+ .finally(() => {
838
+ this.loading.set(false);
839
+ });
840
+ }
841
+ onEditFilterRequest(node) {
842
+ const dialogRef = this._dialog.open(ThesaurusPagedTreeFilterComponent, {
843
+ data: {
844
+ filter: node.filter,
845
+ },
846
+ });
847
+ dialogRef.afterClosed().subscribe((filter) => {
848
+ // undefined = user dismissed without changes
849
+ if (filter === null) {
850
+ this.store().setNodeFilter(node.id, null);
851
+ }
852
+ else if (filter) {
853
+ this.store().setNodeFilter(node.id, filter);
854
+ }
855
+ });
856
+ }
857
+ expandAll() {
858
+ this.store().expandAll();
859
+ }
860
+ collapseAll() {
861
+ this.store().collapseAll();
862
+ }
863
+ clear() {
864
+ this.store().clear();
865
+ }
866
+ onNodeClick(node) {
867
+ if (!node.hasChildren) {
868
+ this.nodePick.emit(node);
869
+ }
870
+ }
871
+ findLabels() {
872
+ if (!this.label.value) {
873
+ return;
874
+ }
875
+ this.store().findLabels(this.label.value);
876
+ }
877
+ removeHilites() {
878
+ this.store().removeHilites();
879
+ }
880
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusBrowserComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
881
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ThesaurusBrowserComponent, isStandalone: true, selector: "cadmus-thesaurus-browser", inputs: { service: { classPropertyName: "service", publicName: "service", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { nodePick: "nodePick" }, ngImport: i0, template: "<div id=\"container\">\r\n <!-- filters -->\r\n <div id=\"filters\" class=\"form-row\">\r\n <form [formGroup]=\"form\" (submit)=\"findLabels()\" class=\"form-row\">\r\n <mat-form-field>\r\n <mat-label>label</mat-label>\r\n <input matInput [formControl]=\"label\" />\r\n @if ($any(label).errors?.required && (label.dirty || label.touched)) {\r\n <mat-error>label required</mat-error>\r\n }\r\n </mat-form-field>\r\n <button type=\"submit\" mat-icon-button class=\"mat-primary\">\r\n <mat-icon>search</mat-icon>\r\n </button>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n (click)=\"removeHilites()\"\r\n class=\"mat-warn\"\r\n >\r\n <mat-icon>clear</mat-icon>\r\n </button>\r\n </form>\r\n <fieldset>\r\n <legend>filter</legend>\r\n <cadmus-thesaurus-paged-tree-filter\r\n [filter]=\"filter$ | async\"\r\n (filterChange)=\"onFilterChange($event)\"\r\n />\r\n </fieldset>\r\n </div>\r\n\r\n <!-- tree -->\r\n <div id=\"tree\">\r\n <!-- progress -->\r\n @if (loading()) {\r\n <div>\r\n <mat-progress-bar mode=\"indeterminate\"></mat-progress-bar>\r\n </div>\r\n }\r\n <!-- nodes -->\r\n @if (nodes$ | async; as nodes) {\r\n <div>\r\n @for (node of nodes; track node.id; let i = $index) {\r\n <div class=\"button-row\" [class.hilite]=\"node.hilite\">\r\n <pdb-browser-tree-node\r\n [node]=\"node\"\r\n [debug]=\"debug.value\"\r\n [paging]=\"\r\n node.expanded &&\r\n i + 1 < nodes.length &&\r\n nodes[i + 1].paging.pageCount > 1\r\n ? nodes[i + 1].paging\r\n : undefined\r\n \"\r\n [hideFilter]=\"hideFilter.value\"\r\n [hideLoc]=\"hideLoc.value\"\r\n (toggleExpandedRequest)=\"onToggleExpanded($any($event))\"\r\n (changePageRequest)=\"onPageChangeRequest($event)\"\r\n (editNodeFilterRequest)=\"onEditFilterRequest($any($event))\"\r\n />\r\n @if (!node.hasChildren) {\r\n <button type=\"button\" mat-icon-button (click)=\"onNodeClick(node)\">\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-row\">\r\n <mat-checkbox [formControl]=\"debug\">debug</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideFilter\">no filter</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideLoc\">no loc.</mat-checkbox>\r\n <button\r\n type=\"button\"\r\n mat-flat-button\r\n (click)=\"collapseAll()\"\r\n style=\"margin-left: 24px\"\r\n >\r\n collapse\r\n </button>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: ["form{margin:16px}.form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.nr{width:6em}fieldset{border:1px solid silver;border-radius:6px;padding:8px 16px;margin:8px 0}legend{color:silver}th{text-align:left;font-weight:400}#tag-selector{width:8em}.tag-chip{border:2px solid transparent;border-radius:6px}.button-row{display:flex;align-items:center;flex-wrap:wrap}.button-row *{flex:0 0 auto}.hilite{background-color:#ffffe0;border:2px solid gold;border-radius:6px;padding:2px 4px}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatProgressBarModule }, { kind: "component", type: i8.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "component", type: ThesaurusPagedTreeFilterComponent, selector: "cadmus-thesaurus-paged-tree-filter", inputs: ["filter"], outputs: ["filterChange"] }, { kind: "component", type: BrowserTreeNodeComponent, selector: "pdb-browser-tree-node", inputs: ["node", "paging", "debug", "hideLabel", "hideLoc", "hidePaging", "hideFilter", "indentSize", "rangeWidth"], outputs: ["toggleExpandedRequest", "changePageRequest", "editNodeFilterRequest"] }, { kind: "pipe", type: AsyncPipe, name: "async" }] }); }
882
+ }
883
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusBrowserComponent, decorators: [{
884
+ type: Component,
885
+ args: [{ selector: 'cadmus-thesaurus-browser', imports: [
886
+ AsyncPipe,
887
+ ReactiveFormsModule,
888
+ MatButtonModule,
889
+ MatCheckboxModule,
890
+ MatFormFieldModule,
891
+ MatIconModule,
892
+ MatInputModule,
893
+ MatProgressBarModule,
894
+ MatTooltipModule,
895
+ ThesaurusPagedTreeFilterComponent,
896
+ BrowserTreeNodeComponent,
897
+ ], template: "<div id=\"container\">\r\n <!-- filters -->\r\n <div id=\"filters\" class=\"form-row\">\r\n <form [formGroup]=\"form\" (submit)=\"findLabels()\" class=\"form-row\">\r\n <mat-form-field>\r\n <mat-label>label</mat-label>\r\n <input matInput [formControl]=\"label\" />\r\n @if ($any(label).errors?.required && (label.dirty || label.touched)) {\r\n <mat-error>label required</mat-error>\r\n }\r\n </mat-form-field>\r\n <button type=\"submit\" mat-icon-button class=\"mat-primary\">\r\n <mat-icon>search</mat-icon>\r\n </button>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n (click)=\"removeHilites()\"\r\n class=\"mat-warn\"\r\n >\r\n <mat-icon>clear</mat-icon>\r\n </button>\r\n </form>\r\n <fieldset>\r\n <legend>filter</legend>\r\n <cadmus-thesaurus-paged-tree-filter\r\n [filter]=\"filter$ | async\"\r\n (filterChange)=\"onFilterChange($event)\"\r\n />\r\n </fieldset>\r\n </div>\r\n\r\n <!-- tree -->\r\n <div id=\"tree\">\r\n <!-- progress -->\r\n @if (loading()) {\r\n <div>\r\n <mat-progress-bar mode=\"indeterminate\"></mat-progress-bar>\r\n </div>\r\n }\r\n <!-- nodes -->\r\n @if (nodes$ | async; as nodes) {\r\n <div>\r\n @for (node of nodes; track node.id; let i = $index) {\r\n <div class=\"button-row\" [class.hilite]=\"node.hilite\">\r\n <pdb-browser-tree-node\r\n [node]=\"node\"\r\n [debug]=\"debug.value\"\r\n [paging]=\"\r\n node.expanded &&\r\n i + 1 < nodes.length &&\r\n nodes[i + 1].paging.pageCount > 1\r\n ? nodes[i + 1].paging\r\n : undefined\r\n \"\r\n [hideFilter]=\"hideFilter.value\"\r\n [hideLoc]=\"hideLoc.value\"\r\n (toggleExpandedRequest)=\"onToggleExpanded($any($event))\"\r\n (changePageRequest)=\"onPageChangeRequest($event)\"\r\n (editNodeFilterRequest)=\"onEditFilterRequest($any($event))\"\r\n />\r\n @if (!node.hasChildren) {\r\n <button type=\"button\" mat-icon-button (click)=\"onNodeClick(node)\">\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-row\">\r\n <mat-checkbox [formControl]=\"debug\">debug</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideFilter\">no filter</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideLoc\">no loc.</mat-checkbox>\r\n <button\r\n type=\"button\"\r\n mat-flat-button\r\n (click)=\"collapseAll()\"\r\n style=\"margin-left: 24px\"\r\n >\r\n collapse\r\n </button>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: ["form{margin:16px}.form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.nr{width:6em}fieldset{border:1px solid silver;border-radius:6px;padding:8px 16px;margin:8px 0}legend{color:silver}th{text-align:left;font-weight:400}#tag-selector{width:8em}.tag-chip{border:2px solid transparent;border-radius:6px}.button-row{display:flex;align-items:center;flex-wrap:wrap}.button-row *{flex:0 0 auto}.hilite{background-color:#ffffe0;border:2px solid gold;border-radius:6px;padding:2px 4px}\n"] }]
898
+ }], ctorParameters: () => [], propDecorators: { service: [{ type: i0.Input, args: [{ isSignal: true, alias: "service", required: false }] }], nodePick: [{ type: i0.Output, args: ["nodePick"] }] } });
899
+
900
+ /**
901
+ * Thesaurus tree component.
902
+ * This component displays a set of hierarchical thesaurus entries
903
+ * in a tree, provided that each entry marks its hierarchy with
904
+ * dots. For instance, say you have the hierarchy "furniture" -
905
+ * "type" - "color". You might have an entry whose ID is
906
+ * "furniture.table.red", with a sibling "furniture.table.green",
907
+ * and a parent "furniture.table". This parent is there only to
908
+ * provide a label to the parent node, but only leaf nodes can be
909
+ * picked by the user. Whenever one is picked, the entryChange
910
+ * event is emitted.
911
+ */
912
+ class ThesaurusTreeComponent {
913
+ constructor() {
914
+ /**
915
+ * The thesaurus entries.
916
+ */
917
+ this.entries = input(...(ngDevMode ? [undefined, { debugName: "entries" }] : []));
918
+ /**
919
+ * The optional node label rendering function. Note that even if you
920
+ * specify a label renderer function, the entryChange event always
921
+ * emits the original label.
922
+ */
923
+ this.renderLabel = input(renderLabelFromLastColon, ...(ngDevMode ? [{ debugName: "renderLabel" }] : []));
924
+ /**
925
+ * Fired when a thesaurus entry is selected.
926
+ */
927
+ this.entryChange = output();
928
+ /**
929
+ * The tree store service, dependent on the current entries and renderLabel.
930
+ */
931
+ this.service = computed(() => {
932
+ const entries = this.entries();
933
+ return new StaticThesaurusPagedTreeStoreService(entries || [], this.renderLabel());
934
+ }, ...(ngDevMode ? [{ debugName: "service" }] : []));
935
+ /**
936
+ * The filter component class to use.
937
+ */
938
+ this.filterComponent = ThesaurusPagedTreeFilterComponent;
939
+ }
940
+ onNodePick(node) {
941
+ // only allow selection of leaf nodes (nodes without children)
942
+ if (!node.hasChildren) {
943
+ // find the original entry
944
+ const entry = this.entries()?.find((e) => e.id === node.key);
945
+ if (entry) {
946
+ this.entryChange.emit(entry);
947
+ }
948
+ }
949
+ }
950
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusTreeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
951
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ThesaurusTreeComponent, isStandalone: true, selector: "cadmus-thesaurus-tree", inputs: { entries: { classPropertyName: "entries", publicName: "entries", isSignal: true, isRequired: false, transformFunction: null }, renderLabel: { classPropertyName: "renderLabel", publicName: "renderLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { entryChange: "entryChange" }, ngImport: i0, template: "<div>\r\n <!-- tree -->\r\n @if (service() && entries()?.length) {\r\n <div class=\"tree-wrapper\">\r\n <cadmus-thesaurus-browser\r\n [service]=\"service()\"\r\n (nodePick)=\"onNodePick($event)\"\r\n />\r\n </div>\r\n }\r\n</div>\r\n", styles: [".th-tree-progress-bar{margin-left:30px}.th-tree-nested-node{padding-left:30px}mat-tree{margin-left:40px}.mat-tree-node{padding:0;background-color:#fff}.mat-nested-tree-node{top:-24px}ul,li{list-style:none;margin:0;padding:0}li.th-tree-container{border-bottom:0}ul{padding-left:40px}li{padding-left:40px;border:1px dotted grey;border-width:0 0 1px 1px;position:relative;top:-24px}li.mat-tree-node,li div{margin:0;position:relative;top:24px}li ul{border-top:1px dotted grey;margin-left:-40px;padding-left:60px}.mat-nested-tree-node:last-child ul{border-left:1px solid white;margin-left:-41px}.mat-mdc-icon-button{z-index:100}span.found-count{margin:0 4px;padding:2px 4px;color:#fff;background-color:#0cc078;border-radius:6px}span.not-found-count{margin:0 4px;padding:2px 4px;color:#fff;background-color:#fb6962;border-radius:6px}.hilite{background-color:#fdfd96}.filter-field{width:100%;margin-bottom:1rem}.tree-container{border:1px solid #e0e0e0;border-radius:4px;overflow:auto;max-height:500px}.tree-wrapper{border:1px solid #e0e0e0;border-radius:4px;overflow:hidden;background:#fff}.no-entries{text-align:center;padding:2rem;color:#666;font-style:italic;border:1px solid #e0e0e0;border-radius:4px;background:#f9f9f9}.no-entries p{margin:0}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "component", type: ThesaurusBrowserComponent, selector: "cadmus-thesaurus-browser", inputs: ["service"], outputs: ["nodePick"] }] }); }
952
+ }
953
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusTreeComponent, decorators: [{
954
+ type: Component,
955
+ args: [{ selector: 'cadmus-thesaurus-tree', imports: [FormsModule, ReactiveFormsModule, ThesaurusBrowserComponent], template: "<div>\r\n <!-- tree -->\r\n @if (service() && entries()?.length) {\r\n <div class=\"tree-wrapper\">\r\n <cadmus-thesaurus-browser\r\n [service]=\"service()\"\r\n (nodePick)=\"onNodePick($event)\"\r\n />\r\n </div>\r\n }\r\n</div>\r\n", styles: [".th-tree-progress-bar{margin-left:30px}.th-tree-nested-node{padding-left:30px}mat-tree{margin-left:40px}.mat-tree-node{padding:0;background-color:#fff}.mat-nested-tree-node{top:-24px}ul,li{list-style:none;margin:0;padding:0}li.th-tree-container{border-bottom:0}ul{padding-left:40px}li{padding-left:40px;border:1px dotted grey;border-width:0 0 1px 1px;position:relative;top:-24px}li.mat-tree-node,li div{margin:0;position:relative;top:24px}li ul{border-top:1px dotted grey;margin-left:-40px;padding-left:60px}.mat-nested-tree-node:last-child ul{border-left:1px solid white;margin-left:-41px}.mat-mdc-icon-button{z-index:100}span.found-count{margin:0 4px;padding:2px 4px;color:#fff;background-color:#0cc078;border-radius:6px}span.not-found-count{margin:0 4px;padding:2px 4px;color:#fff;background-color:#fb6962;border-radius:6px}.hilite{background-color:#fdfd96}.filter-field{width:100%;margin-bottom:1rem}.tree-container{border:1px solid #e0e0e0;border-radius:4px;overflow:auto;max-height:500px}.tree-wrapper{border:1px solid #e0e0e0;border-radius:4px;overflow:hidden;background:#fff}.no-entries{text-align:center;padding:2rem;color:#666;font-style:italic;border:1px solid #e0e0e0;border-radius:4px;background:#f9f9f9}.no-entries p{margin:0}\n"] }]
956
+ }], propDecorators: { entries: [{ type: i0.Input, args: [{ isSignal: true, alias: "entries", required: false }] }], renderLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "renderLabel", required: false }] }], entryChange: [{ type: i0.Output, args: ["entryChange"] }] } });
957
+
958
+ /**
959
+ * The prefix added to custom entries' IDs.
960
+ */
961
+ const CUSTOM_ENTRY_PREFIX = '$';
962
+ /**
963
+ * A picker component for thesaurus entries.
964
+ * This component allows picking one or more entries from a given thesaurus.
965
+ * In its collapsed state, it shows the picked entries as chips; when
966
+ * expanded, it shows the thesaurus tree to pick from. Custom entries
967
+ * (not in the thesaurus) can be optionally allowed.
968
+ */
969
+ class ThesaurusEntriesPickerComponent {
970
+ constructor(formBuilder) {
971
+ /**
972
+ * The thesaurus entries to pick from (required).
973
+ */
974
+ this.availableEntries = input.required(...(ngDevMode ? [{ debugName: "availableEntries" }] : []));
975
+ /**
976
+ * The picked entries.
977
+ */
978
+ this.entries = model([], ...(ngDevMode ? [{ debugName: "entries" }] : []));
979
+ /**
980
+ * True to show the entries with labels shortened according to
981
+ * their hierarchy.
982
+ */
983
+ this.hierarchicLabels = input(false, ...(ngDevMode ? [{ debugName: "hierarchicLabels" }] : []));
984
+ /**
985
+ * True to automatically sort the picked entries (disables drag-and-drop).
986
+ * When false, entries can be manually reordered via drag-and-drop.
987
+ */
988
+ this.autoSort = input(false, ...(ngDevMode ? [{ debugName: "autoSort" }] : []));
989
+ /**
990
+ * True to allow custom values (not in the entries list).
991
+ */
992
+ this.allowCustom = input(false, ...(ngDevMode ? [{ debugName: "allowCustom" }] : []));
993
+ /**
994
+ * The minimum number of entries to pick.
995
+ */
996
+ this.minEntries = input(0, ...(ngDevMode ? [{ debugName: "minEntries" }] : []));
997
+ /**
998
+ * The maximum number of entries to pick (0=unlimited).
999
+ */
1000
+ this.maxEntries = input(0, ...(ngDevMode ? [{ debugName: "maxEntries" }] : []));
1001
+ /**
1002
+ * True when the picker is expanded (showing the entries list).
1003
+ */
1004
+ this.expanded = model(false, ...(ngDevMode ? [{ debugName: "expanded" }] : []));
1005
+ /**
1006
+ * The message to show when there are no picked entries.
1007
+ */
1008
+ this.emptyMessage = input('no entries', ...(ngDevMode ? [{ debugName: "emptyMessage" }] : []));
1009
+ /**
1010
+ * The number of remaining entries that can be picked (0=unlimited).
1011
+ * This is displayed only if maxEntries > 1.
1012
+ */
1013
+ this.remaining = computed(() => {
1014
+ if (this.maxEntries() > 0) {
1015
+ return this.maxEntries() - this.entries().length;
1016
+ }
1017
+ return 0;
1018
+ }, ...(ngDevMode ? [{ debugName: "remaining" }] : []));
1019
+ // need arrow function to use 'this'
1020
+ this.renderLabel = (label) => {
1021
+ return this.hierarchicLabels() ? renderLabelFromLastColon(label) : label;
1022
+ };
1023
+ this.id = formBuilder.control('', {
1024
+ validators: [Validators.required, Validators.maxLength(100)],
1025
+ nonNullable: true,
1026
+ });
1027
+ this.value = formBuilder.control('', {
1028
+ validators: [Validators.required, Validators.maxLength(500)],
1029
+ nonNullable: true,
1030
+ });
1031
+ this.form = formBuilder.group({
1032
+ id: this.id,
1033
+ value: this.value,
1034
+ });
1035
+ // if auto-sort is turned on and we have entries, sort them
1036
+ effect(() => {
1037
+ // only track autoSort() changes, not entries()
1038
+ const shouldSort = this.autoSort();
1039
+ if (shouldSort) {
1040
+ // use untracked to read entries without creating a dependency
1041
+ const currentEntries = untracked(() => this.entries());
1042
+ if (currentEntries.length > 1) {
1043
+ const entries = [...currentEntries];
1044
+ this.sortEntries(entries);
1045
+ this.entries.set(entries);
1046
+ }
1047
+ }
1048
+ });
1049
+ }
1050
+ sortEntries(entries) {
1051
+ entries.sort((a, b) => {
1052
+ const aIsCustom = a.id?.startsWith(CUSTOM_ENTRY_PREFIX) ?? false;
1053
+ const bIsCustom = b.id?.startsWith(CUSTOM_ENTRY_PREFIX) ?? false;
1054
+ if (aIsCustom !== bIsCustom) {
1055
+ // place custom entries after non-custom ones
1056
+ return aIsCustom ? 1 : -1;
1057
+ }
1058
+ // same kind: sort by label/value
1059
+ return a.value.localeCompare(b.value);
1060
+ });
1061
+ }
1062
+ onEntryChange(entry) {
1063
+ // check if already present
1064
+ if (this.entries().some((e) => e.id === entry.id)) {
1065
+ return;
1066
+ }
1067
+ // check if limit is reached
1068
+ if (this.maxEntries() && this.entries().length >= this.maxEntries()) {
1069
+ return;
1070
+ }
1071
+ const entries = [...this.entries()];
1072
+ entries.push(entry);
1073
+ if (this.autoSort()) {
1074
+ this.sortEntries(entries);
1075
+ }
1076
+ this.entries.set(entries);
1077
+ }
1078
+ addCustomEntry() {
1079
+ if (this.form.invalid) {
1080
+ return;
1081
+ }
1082
+ // create the custom entry
1083
+ const customEntry = {
1084
+ id: CUSTOM_ENTRY_PREFIX + this.id.value,
1085
+ value: this.value.value,
1086
+ };
1087
+ // check if already present
1088
+ if (this.entries().some((e) => e.id === customEntry.id)) {
1089
+ return;
1090
+ }
1091
+ // check if limit is reached
1092
+ if (this.maxEntries() && this.entries().length >= this.maxEntries()) {
1093
+ return;
1094
+ }
1095
+ const entries = [...this.entries()];
1096
+ entries.push(customEntry);
1097
+ if (this.autoSort()) {
1098
+ this.sortEntries(entries);
1099
+ }
1100
+ this.entries.set(entries);
1101
+ this.form.reset();
1102
+ }
1103
+ removeEntry(entry) {
1104
+ const entries = [...this.entries().filter((e) => e.id !== entry.id)];
1105
+ this.entries.set(entries);
1106
+ }
1107
+ clear() {
1108
+ this.entries.set([]);
1109
+ // if min > 0, expand the picker
1110
+ if (this.minEntries() > 0 && !this.expanded()) {
1111
+ setTimeout(() => {
1112
+ this.expanded.set(true);
1113
+ }, 0);
1114
+ }
1115
+ }
1116
+ onDrop(event) {
1117
+ const entries = [...this.entries()];
1118
+ moveItemInArray(entries, event.previousIndex, event.currentIndex);
1119
+ this.entries.set(entries);
1120
+ }
1121
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusEntriesPickerComponent, deps: [{ token: i1.FormBuilder }], target: i0.ɵɵFactoryTarget.Component }); }
1122
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ThesaurusEntriesPickerComponent, isStandalone: true, selector: "cadmus-thesaurus-entries-picker", inputs: { availableEntries: { classPropertyName: "availableEntries", publicName: "availableEntries", isSignal: true, isRequired: true, transformFunction: null }, entries: { classPropertyName: "entries", publicName: "entries", isSignal: true, isRequired: false, transformFunction: null }, hierarchicLabels: { classPropertyName: "hierarchicLabels", publicName: "hierarchicLabels", isSignal: true, isRequired: false, transformFunction: null }, autoSort: { classPropertyName: "autoSort", publicName: "autoSort", isSignal: true, isRequired: false, transformFunction: null }, allowCustom: { classPropertyName: "allowCustom", publicName: "allowCustom", isSignal: true, isRequired: false, transformFunction: null }, minEntries: { classPropertyName: "minEntries", publicName: "minEntries", isSignal: true, isRequired: false, transformFunction: null }, maxEntries: { classPropertyName: "maxEntries", publicName: "maxEntries", isSignal: true, isRequired: false, transformFunction: null }, expanded: { classPropertyName: "expanded", publicName: "expanded", isSignal: true, isRequired: false, transformFunction: null }, emptyMessage: { classPropertyName: "emptyMessage", publicName: "emptyMessage", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { entries: "entriesChange", expanded: "expandedChange" }, ngImport: i0, template: "<div id=\"container\">\r\n <!-- picked entries -->\r\n <div id=\"picked\" class=\"form-row\">\r\n <!-- count -->\r\n <span class=\"nr\" [class.error]=\"entries().length < minEntries()\">{{\r\n entries().length\r\n }}</span>\r\n <!-- max-->\r\n @if (maxEntries() > 1) {\r\n <span class=\"muted\">/{{ maxEntries() }}</span>\r\n @if (remaining()) {\r\n <span class=\"muted\">: -{{ remaining() }}</span>\r\n } }\r\n <!-- min -->\r\n @if (minEntries()) {\r\n <!-- min with error -->\r\n @if (entries().length < minEntries()) {\r\n <span class=\"error\"> (min {{ minEntries() }})</span>\r\n }\r\n <!-- min without error -->\r\n @else {\r\n <span class=\"muted\"> (min {{ minEntries() }})</span>\r\n } }\r\n <!-- list -->\r\n <mat-chip-listbox\r\n [cdkDropListDisabled]=\"autoSort()\"\r\n cdkDropList\r\n cdkDropListOrientation=\"horizontal\"\r\n (cdkDropListDropped)=\"onDrop($event)\"\r\n class=\"chip-list\"\r\n >\r\n @for (e of entries(); track e.id; let idx = $index) {\r\n <div class=\"chip-wrapper\">\r\n <mat-chip-option\r\n [cdkDragDisabled]=\"autoSort()\"\r\n cdkDrag\r\n [class.error]=\"!allowCustom() && e.id.startsWith('$')\"\r\n >\r\n {{ renderLabel(e.value) }}\r\n <button\r\n type=\"button\"\r\n matChipRemove\r\n (click)=\"removeEntry(e)\"\r\n aria-label=\"Remove entry\"\r\n >\r\n <mat-icon class=\"mat-warn\">cancel</mat-icon>\r\n </button>\r\n </mat-chip-option>\r\n </div>\r\n } @empty {\r\n <span class=\"muted empty-message\">{{ emptyMessage() }}</span>\r\n }\r\n </mat-chip-listbox>\r\n\r\n <!-- clear -->\r\n @if (entries().length > 0) {\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Remove all entries\"\r\n (click)=\"clear()\"\r\n [disabled]=\"entries().length === 0\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n }\r\n <!-- toggler -->\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Toggle picker\"\r\n (click)=\"expanded.set(!expanded())\"\r\n >\r\n @if (expanded()) {\r\n <mat-icon class=\"mat-primary\">publish</mat-icon>\r\n } @if (!expanded()) {\r\n <mat-icon class=\"mat-primary\">get_app</mat-icon>\r\n }\r\n </button>\r\n </div>\r\n\r\n <!-- entries picker -->\r\n <div id=\"picker\">\r\n <mat-expansion-panel\r\n [expanded]=\"expanded()\"\r\n (expandedChange)=\"expanded.set($event)\"\r\n >\r\n <div>\r\n <fieldset>\r\n <legend>available</legend>\r\n <cadmus-thesaurus-tree\r\n [entries]=\"availableEntries()\"\r\n [renderLabel]=\"renderLabel\"\r\n (entryChange)=\"onEntryChange($event)\"\r\n />\r\n </fieldset>\r\n </div>\r\n @if (allowCustom()) {\r\n <div>\r\n <form [formGroup]=\"form\" (submit)=\"addCustomEntry()\">\r\n <fieldset>\r\n <legend>custom</legend>\r\n <div class=\"form-row\">\r\n <mat-form-field>\r\n <input\r\n matInput\r\n type=\"text\"\r\n [formControl]=\"id\"\r\n placeholder=\"ID\"\r\n />\r\n </mat-form-field>\r\n <mat-form-field>\r\n <input\r\n matInput\r\n type=\"text\"\r\n [formControl]=\"value\"\r\n placeholder=\"label\"\r\n />\r\n </mat-form-field>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Add custom entry\"\r\n [disabled]=\"\r\n !form.valid ||\r\n (maxEntries() > 0 && entries().length >= maxEntries())\r\n \"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </fieldset>\r\n </form>\r\n </div>\r\n }\r\n </mat-expansion-panel>\r\n </div>\r\n</div>\r\n", styles: ["#container{width:100%}.form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.error{color:#8b0000}.nr{font-weight:700}.muted{color:gray}fieldset{border:1px solid silver;border-radius:8px;padding:8px 16px}.chip-list{display:flex;flex-wrap:wrap;gap:4px;align-items:center;padding:4px}.chip-wrapper{display:inline-flex;position:relative;margin:2px}.chip-list mat-chip-option:not([cdkDragDisabled]){cursor:grab}.chip-list mat-chip-option:not([cdkDragDisabled]):active{cursor:grabbing}.chip-list mat-chip-option[cdkDragDisabled]{cursor:default}.chip-list .empty-message{margin-left:0}.chip-list mat-chip-option.mat-mdc-chip-selected{background-color:inherit}.chip-list mat-chip-option:not(.error).mat-mdc-chip-selected{background-color:#e0e0e0}mat-chip-option.error{background-color:#ffebee!important;border:1px solid darkred}.cdk-drag-preview{opacity:.95;box-shadow:0 8px 20px #0006;transform:rotate(3deg)}.drag-placeholder{width:4px;background:#1976d2;border-radius:2px;height:36px;box-shadow:0 0 8px #1976d299;animation:pulse .5s ease-in-out infinite}@keyframes pulse{0%,to{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.1)}}.chip-list .cdk-drag-placeholder{opacity:0;width:4px;min-width:4px}.chip-wrapper{transition:transform .25s cubic-bezier(0,0,.2,1)}.cdk-drop-list-dragging .chip-wrapper:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}.cdk-drop-list-dragging .chip-wrapper{will-change:transform}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer", "cdkDropListHasAnchor"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "component", type: MatChipListbox, selector: "mat-chip-listbox", inputs: ["multiple", "aria-orientation", "selectable", "compareWith", "required", "hideSingleSelectionIndicator", "value"], outputs: ["change"] }, { kind: "component", type: MatChipOption, selector: "mat-basic-chip-option, [mat-basic-chip-option], mat-chip-option, [mat-chip-option]", inputs: ["selectable", "selected"], outputs: ["selectionChange"] }, { kind: "directive", type: MatChipRemove, selector: "[matChipRemove]" }, { kind: "component", type: MatExpansionPanel, selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ThesaurusTreeComponent, selector: "cadmus-thesaurus-tree", inputs: ["entries", "renderLabel"], outputs: ["entryChange"] }] }); }
1123
+ }
1124
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusEntriesPickerComponent, decorators: [{
1125
+ type: Component,
1126
+ args: [{ selector: 'cadmus-thesaurus-entries-picker', imports: [
1127
+ ReactiveFormsModule,
1128
+ CdkDropList,
1129
+ CdkDrag,
1130
+ MatChipListbox,
1131
+ MatChipOption,
1132
+ MatChipRemove,
1133
+ MatExpansionPanel,
1134
+ MatFormField,
1135
+ MatIconButton,
1136
+ MatIcon,
1137
+ MatInput,
1138
+ MatTooltip,
1139
+ ThesaurusTreeComponent,
1140
+ ], template: "<div id=\"container\">\r\n <!-- picked entries -->\r\n <div id=\"picked\" class=\"form-row\">\r\n <!-- count -->\r\n <span class=\"nr\" [class.error]=\"entries().length < minEntries()\">{{\r\n entries().length\r\n }}</span>\r\n <!-- max-->\r\n @if (maxEntries() > 1) {\r\n <span class=\"muted\">/{{ maxEntries() }}</span>\r\n @if (remaining()) {\r\n <span class=\"muted\">: -{{ remaining() }}</span>\r\n } }\r\n <!-- min -->\r\n @if (minEntries()) {\r\n <!-- min with error -->\r\n @if (entries().length < minEntries()) {\r\n <span class=\"error\"> (min {{ minEntries() }})</span>\r\n }\r\n <!-- min without error -->\r\n @else {\r\n <span class=\"muted\"> (min {{ minEntries() }})</span>\r\n } }\r\n <!-- list -->\r\n <mat-chip-listbox\r\n [cdkDropListDisabled]=\"autoSort()\"\r\n cdkDropList\r\n cdkDropListOrientation=\"horizontal\"\r\n (cdkDropListDropped)=\"onDrop($event)\"\r\n class=\"chip-list\"\r\n >\r\n @for (e of entries(); track e.id; let idx = $index) {\r\n <div class=\"chip-wrapper\">\r\n <mat-chip-option\r\n [cdkDragDisabled]=\"autoSort()\"\r\n cdkDrag\r\n [class.error]=\"!allowCustom() && e.id.startsWith('$')\"\r\n >\r\n {{ renderLabel(e.value) }}\r\n <button\r\n type=\"button\"\r\n matChipRemove\r\n (click)=\"removeEntry(e)\"\r\n aria-label=\"Remove entry\"\r\n >\r\n <mat-icon class=\"mat-warn\">cancel</mat-icon>\r\n </button>\r\n </mat-chip-option>\r\n </div>\r\n } @empty {\r\n <span class=\"muted empty-message\">{{ emptyMessage() }}</span>\r\n }\r\n </mat-chip-listbox>\r\n\r\n <!-- clear -->\r\n @if (entries().length > 0) {\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Remove all entries\"\r\n (click)=\"clear()\"\r\n [disabled]=\"entries().length === 0\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n }\r\n <!-- toggler -->\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Toggle picker\"\r\n (click)=\"expanded.set(!expanded())\"\r\n >\r\n @if (expanded()) {\r\n <mat-icon class=\"mat-primary\">publish</mat-icon>\r\n } @if (!expanded()) {\r\n <mat-icon class=\"mat-primary\">get_app</mat-icon>\r\n }\r\n </button>\r\n </div>\r\n\r\n <!-- entries picker -->\r\n <div id=\"picker\">\r\n <mat-expansion-panel\r\n [expanded]=\"expanded()\"\r\n (expandedChange)=\"expanded.set($event)\"\r\n >\r\n <div>\r\n <fieldset>\r\n <legend>available</legend>\r\n <cadmus-thesaurus-tree\r\n [entries]=\"availableEntries()\"\r\n [renderLabel]=\"renderLabel\"\r\n (entryChange)=\"onEntryChange($event)\"\r\n />\r\n </fieldset>\r\n </div>\r\n @if (allowCustom()) {\r\n <div>\r\n <form [formGroup]=\"form\" (submit)=\"addCustomEntry()\">\r\n <fieldset>\r\n <legend>custom</legend>\r\n <div class=\"form-row\">\r\n <mat-form-field>\r\n <input\r\n matInput\r\n type=\"text\"\r\n [formControl]=\"id\"\r\n placeholder=\"ID\"\r\n />\r\n </mat-form-field>\r\n <mat-form-field>\r\n <input\r\n matInput\r\n type=\"text\"\r\n [formControl]=\"value\"\r\n placeholder=\"label\"\r\n />\r\n </mat-form-field>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Add custom entry\"\r\n [disabled]=\"\r\n !form.valid ||\r\n (maxEntries() > 0 && entries().length >= maxEntries())\r\n \"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </fieldset>\r\n </form>\r\n </div>\r\n }\r\n </mat-expansion-panel>\r\n </div>\r\n</div>\r\n", styles: ["#container{width:100%}.form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.error{color:#8b0000}.nr{font-weight:700}.muted{color:gray}fieldset{border:1px solid silver;border-radius:8px;padding:8px 16px}.chip-list{display:flex;flex-wrap:wrap;gap:4px;align-items:center;padding:4px}.chip-wrapper{display:inline-flex;position:relative;margin:2px}.chip-list mat-chip-option:not([cdkDragDisabled]){cursor:grab}.chip-list mat-chip-option:not([cdkDragDisabled]):active{cursor:grabbing}.chip-list mat-chip-option[cdkDragDisabled]{cursor:default}.chip-list .empty-message{margin-left:0}.chip-list mat-chip-option.mat-mdc-chip-selected{background-color:inherit}.chip-list mat-chip-option:not(.error).mat-mdc-chip-selected{background-color:#e0e0e0}mat-chip-option.error{background-color:#ffebee!important;border:1px solid darkred}.cdk-drag-preview{opacity:.95;box-shadow:0 8px 20px #0006;transform:rotate(3deg)}.drag-placeholder{width:4px;background:#1976d2;border-radius:2px;height:36px;box-shadow:0 0 8px #1976d299;animation:pulse .5s ease-in-out infinite}@keyframes pulse{0%,to{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.1)}}.chip-list .cdk-drag-placeholder{opacity:0;width:4px;min-width:4px}.chip-wrapper{transition:transform .25s cubic-bezier(0,0,.2,1)}.cdk-drop-list-dragging .chip-wrapper:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}.cdk-drop-list-dragging .chip-wrapper{will-change:transform}\n"] }]
1141
+ }], ctorParameters: () => [{ type: i1.FormBuilder }], propDecorators: { availableEntries: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableEntries", required: true }] }], entries: [{ type: i0.Input, args: [{ isSignal: true, alias: "entries", required: false }] }, { type: i0.Output, args: ["entriesChange"] }], hierarchicLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "hierarchicLabels", required: false }] }], autoSort: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoSort", required: false }] }], allowCustom: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustom", required: false }] }], minEntries: [{ type: i0.Input, args: [{ isSignal: true, alias: "minEntries", required: false }] }], maxEntries: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxEntries", required: false }] }], expanded: [{ type: i0.Input, args: [{ isSignal: true, alias: "expanded", required: false }] }, { type: i0.Output, args: ["expandedChange"] }], emptyMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyMessage", required: false }] }] } });
1142
+
1143
+ /**
1144
+ * Editable version of the static thesaurus paged tree store service.
1145
+ * This service allows editing operations on the flat ThesaurusEntry list
1146
+ * while maintaining the tree structure implied by the hierarchical IDs.
1147
+ */
1148
+ class EditableStaticThesaurusPagedTreeStoreService extends EditablePagedTreeStoreServiceBase {
1149
+ constructor(entries, renderLabel) {
1150
+ super();
1151
+ this._entries = [...entries];
1152
+ this._renderLabel = renderLabel;
1153
+ this._staticService = new StaticThesaurusPagedTreeStoreService(this._entries, renderLabel);
1154
+ }
1155
+ fetchNodes(filter, pageNumber, pageSize, hasMockRoot) {
1156
+ // update the static service with current entries and delegate,
1157
+ // preserving the renderLabel function
1158
+ this._staticService = new StaticThesaurusPagedTreeStoreService(this._entries, this._renderLabel);
1159
+ return this._staticService.getNodes(filter, pageNumber, pageSize, hasMockRoot);
1160
+ }
1161
+ persistChanges(changes) {
1162
+ console.log('=== SAVING CHANGES ===');
1163
+ console.log('Changes to persist:', changes);
1164
+ const idMap = new Map();
1165
+ // process changes to update the flat entries list
1166
+ for (const change of changes) {
1167
+ switch (change.type) {
1168
+ case ChangeOperationType.ADD:
1169
+ if (change.node) {
1170
+ const newEntry = this.nodeToEntry(change.node);
1171
+ if (newEntry) {
1172
+ const existingIndex = this._entries.findIndex((e) => e.id === newEntry.id);
1173
+ if (existingIndex === -1) {
1174
+ this._entries.push(newEntry);
1175
+ // map temporary ID to the new permanent ID (array index + 1)
1176
+ if (change.node.id < 0) {
1177
+ const permanentId = this._entries.length;
1178
+ idMap.set(change.node.id, permanentId);
1179
+ }
1180
+ console.log('Added entry:', newEntry);
1181
+ }
1182
+ else {
1183
+ console.log('Entry already exists, skipping:', newEntry.id);
1184
+ }
1185
+ }
1186
+ }
1187
+ break;
1188
+ case ChangeOperationType.REMOVE:
1189
+ if (change.originalNode) {
1190
+ // use the 'key' property which contains the ThesaurusEntry string ID
1191
+ const nodeWithKey = change.originalNode;
1192
+ const entryKey = nodeWithKey.key;
1193
+ if (entryKey) {
1194
+ this.removeEntryAndDescendants(entryKey);
1195
+ console.log('Removed entry and descendants:', entryKey);
1196
+ }
1197
+ else {
1198
+ console.warn('Node missing key property:', change.originalNode);
1199
+ }
1200
+ }
1201
+ else if (change.id < 0) {
1202
+ console.log('Removed temporary node that was never persisted:', change.id);
1203
+ }
1204
+ else {
1205
+ console.warn('Remove operation missing originalNode for ID:', change.id);
1206
+ }
1207
+ break;
1208
+ case ChangeOperationType.UPDATE:
1209
+ if (change.node && change.originalNode) {
1210
+ const nodeWithKey = change.originalNode;
1211
+ const entryKey = nodeWithKey.key;
1212
+ if (entryKey) {
1213
+ const entryToUpdate = this._entries.find((e) => e.id === entryKey);
1214
+ if (entryToUpdate) {
1215
+ const updatedEntry = this.nodeToEntry(change.node);
1216
+ if (updatedEntry) {
1217
+ // preserve the original hierarchical ID
1218
+ updatedEntry.id = entryToUpdate.id;
1219
+ const index = this._entries.findIndex((e) => e.id === entryToUpdate.id);
1220
+ if (index !== -1) {
1221
+ this._entries[index] = updatedEntry;
1222
+ console.log('Updated entry:', updatedEntry);
1223
+ }
1224
+ }
1225
+ }
1226
+ else {
1227
+ console.warn('Could not find entry to update with key:', entryKey);
1228
+ }
1229
+ }
1230
+ else {
1231
+ console.warn('Update node missing key property:', change.originalNode);
1232
+ }
1233
+ }
1234
+ else if (change.id < 0) {
1235
+ console.log('Updated temporary node:', change.id);
1236
+ }
1237
+ break;
1238
+ }
1239
+ }
1240
+ // Validate no duplicate IDs
1241
+ const ids = this._entries.map((e) => e.id);
1242
+ const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index);
1243
+ if (duplicates.length > 0) {
1244
+ console.error('Duplicate IDs found:', duplicates);
1245
+ }
1246
+ console.log('Final entries after changes:', this._entries);
1247
+ console.log('ID mappings:', idMap);
1248
+ console.log('=== END SAVING CHANGES ===');
1249
+ return of(idMap);
1250
+ }
1251
+ /**
1252
+ * Convert a TreeNode back to a ThesaurusEntry.
1253
+ */
1254
+ nodeToEntry(node) {
1255
+ // For nodes with the key property, use it directly
1256
+ const nodeWithKey = node;
1257
+ if (nodeWithKey.key) {
1258
+ return {
1259
+ id: nodeWithKey.key,
1260
+ value: node.label,
1261
+ };
1262
+ }
1263
+ // For temporary nodes (negative IDs), construct a hierarchical ID
1264
+ if (node.id < 0) {
1265
+ // Check for user-provided ID
1266
+ const userProvidedId = nodeWithKey._userProvidedId;
1267
+ if (userProvidedId) {
1268
+ return {
1269
+ id: userProvidedId,
1270
+ value: node.label,
1271
+ };
1272
+ }
1273
+ // Find parent to construct the ID
1274
+ if (node.parentId) {
1275
+ const parentNode = this._nodes.get(node.parentId);
1276
+ const parentKey = parentNode?.key;
1277
+ if (parentKey) {
1278
+ return {
1279
+ id: `${parentKey}.temp${Math.abs(node.id)}`,
1280
+ value: node.label,
1281
+ };
1282
+ }
1283
+ }
1284
+ // No parent, create a root-level temporary entry
1285
+ return {
1286
+ id: `temp${Math.abs(node.id)}`,
1287
+ value: node.label,
1288
+ };
1289
+ }
1290
+ console.warn('Could not convert node to entry:', node);
1291
+ return null;
1292
+ }
1293
+ /**
1294
+ * Remove an entry and all its descendants from the entries list.
1295
+ */
1296
+ removeEntryAndDescendants(entryId) {
1297
+ const toRemove = [];
1298
+ // find the entry and all its descendants
1299
+ for (const entry of this._entries) {
1300
+ if (entry.id === entryId || entry.id.startsWith(entryId + '.')) {
1301
+ toRemove.push(entry.id);
1302
+ }
1303
+ }
1304
+ // remove them from the list
1305
+ for (const id of toRemove) {
1306
+ const index = this._entries.findIndex((e) => e.id === id);
1307
+ if (index !== -1) {
1308
+ this._entries.splice(index, 1);
1309
+ }
1310
+ }
1311
+ }
1312
+ /**
1313
+ * Get current entries (for demonstration purposes).
1314
+ */
1315
+ getCurrentEntries() {
1316
+ return [...this._entries];
1317
+ }
1318
+ }
1319
+
1320
+ /*
1321
+ * Public API Surface of cadmus-thesaurus-store
1322
+ */
1323
+
1324
+ /**
1325
+ * Generated bundle index. Do not edit.
1326
+ */
1327
+
1328
+ export { CUSTOM_ENTRY_PREFIX, EditableStaticThesaurusPagedTreeStoreService, EditableThesaurusBrowserComponent, StaticThesaurusPagedTreeStoreService, ThesEntryEditorComponent, ThesaurusBrowserComponent, ThesaurusEntriesPickerComponent, ThesaurusPagedTreeFilterComponent, ThesaurusTreeComponent, renderLabelFromLastColon };
1329
+ //# sourceMappingURL=myrmidon-cadmus-thesaurus-store.mjs.map