diff --git a/libs/dc-workflow/projects/dc-workflow/src/lib/abstract/abstract-dc-workflow.ts b/libs/dc-workflow/projects/dc-workflow/src/lib/abstract/abstract-dc-workflow.ts index 1541105a..6ca0e493 100644 --- a/libs/dc-workflow/projects/dc-workflow/src/lib/abstract/abstract-dc-workflow.ts +++ b/libs/dc-workflow/projects/dc-workflow/src/lib/abstract/abstract-dc-workflow.ts @@ -1,5 +1,5 @@ import { DcWorkflow } from '../definition/dc-workflow'; -import { Component, computed, effect, inject, input, linkedSignal, signal } from '@angular/core'; +import { Component, computed, effect, inject, input, linkedSignal, model, signal } from '@angular/core'; import { DcWorkflowModel, DcWorkflowSortOption } from '../models/dc-workflow-model'; import { XtCompositeComponent, XtMessageHandler, XtResolverService } from 'xt-components'; import { @@ -56,6 +56,46 @@ export class AbstractDcWorkflow extends XtCom return this.config().entity; }); + /** Search string used to filter the displayed items (case-insensitive substring match). */ + search = model(''); + + /** + * Updates the search string from a search box input event. + * @protected + */ + protected onSearchInput(event: Event): void { + this.search.set((event.target as HTMLInputElement).value); + } + + /** + * Computed signal returning displayable elements filtered by the search string. + * Matching is case-insensitive and only items containing the string are returned. + * @protected + */ + protected searchedElements = computed(() => { + const elements = this.displayableElements(); + const query = this.search().trim().toLowerCase(); + if (query === '') { + return elements; + } + return elements.filter((element) => this.matchesSearch(element, query)); + }); + + /** + * Checks whether any value of the item contains the search query (case-insensitive). + * Recursively inspects nested objects and dates. + * @protected + */ + private matchesSearch(value: unknown, query: string): boolean { + if (value == null) return false; + if (value instanceof Date) return value.toLocaleDateString().toLowerCase().includes(query); + if (Array.isArray(value)) return value.some((v) => this.matchesSearch(v, query)); + if (typeof value === 'object') { + return Object.values(value).some((v) => this.matchesSearch(v, query)); + } + return String(value).toLowerCase().includes(query); + } + /** * Toggle signal that changes whenever a new store is created. * Used to force recomputation of derived signals (e.g., displayableElements). diff --git a/libs/xt-components/projects/xt-components/src/lib/render/xt-render.component.spec.ts b/libs/xt-components/projects/xt-components/src/lib/render/xt-render.component.spec.ts index 2b254695..7a7085cc 100644 --- a/libs/xt-components/projects/xt-components/src/lib/render/xt-render.component.spec.ts +++ b/libs/xt-components/projects/xt-components/src/lib/render/xt-render.component.spec.ts @@ -108,6 +108,11 @@ describe('XtRenderComponent', () => { template: '@if (isInForm()) {} @else {

Value is {{context().displayValue()}}

}' }) export class TestCurrencyComponent extends XtSimpleComponent { + override ngOnInit() { + super.ngOnInit(); + // Test the context is correctly set before ngOnInit + const context = this.context(); + } } @Component({ diff --git a/libs/xt-type/src/handler/xt-type-handler.spec.ts b/libs/xt-type/src/handler/xt-type-handler.spec.ts index d52eec4f..adf685d2 100644 --- a/libs/xt-type/src/handler/xt-type-handler.spec.ts +++ b/libs/xt-type/src/handler/xt-type-handler.spec.ts @@ -213,6 +213,123 @@ describe('Type Handler', () => { }); +describe('Type sorting', () => { + + function handlerFor (typeName:string, handler:AbstractTypeHandler):AbstractTypeHandler { + const typeHierarchy = new XtBaseTypeHierarchy(typeName, handler); + typeHierarchy.initHandler(); + return handler; + } + + it ('should mark all primitive types as sortable', () => { + for (const typeName of ['string', 'number', 'boolean', 'date', 'date-time', 'time']) { + const handler = handlerFor(typeName, new DefaultTypeHandler()); + expect(handler.isSortable(), typeName).toBe(true); + } + }); + + it ('should not mark an unknown complex type as sortable', () => { + const handler = handlerFor('person', new DefaultTypeHandler()); + expect(handler.isSortable()).toBe(false); + }); + + it ('should mark a type with a numeric field as sortable', () => { + const handler = handlerFor('money', new ManagedDataHandler( + new SpecialFields().setNumericValueField('amount') + )); + expect(handler.isSortable()).toBe(true); + }); + + it ('should compare strings', () => { + const handler = handlerFor('string', new DefaultTypeHandler()); + expect(handler.compareTo('abc', 'abc')).toBe(0); + expect(handler.compareTo('abc', 'abd')).toBeLessThan(0); + expect(handler.compareTo('abd', 'abc')).toBeGreaterThan(0); + }); + + it ('should compare numbers', () => { + const handler = handlerFor('number', new DefaultTypeHandler()); + expect(handler.compareTo(3, 3)).toBe(0); + expect(handler.compareTo(2, 3)).toBeLessThan(0); + expect(handler.compareTo(3, 2)).toBeGreaterThan(0); + }); + + it ('should compare booleans', () => { + const handler = handlerFor('boolean', new DefaultTypeHandler()); + expect(handler.compareTo(false, false)).toBe(0); + expect(handler.compareTo(true, true)).toBe(0); + expect(handler.compareTo(false, true)).toBeLessThan(0); + expect(handler.compareTo(true, false)).toBeGreaterThan(0); + }); + + it ('should compare dates', () => { + const handler = handlerFor('date', new DefaultTypeHandler()); + const first = new Date('2018-05-01'); + const second = new Date('2019-05-01'); + expect(handler.compareTo(first, first)).toBe(0); + expect(handler.compareTo(first, second)).toBeLessThan(0); + expect(handler.compareTo(second, first)).toBeGreaterThan(0); + }); + + it ('should compare date-time values', () => { + const handler = handlerFor('date-time', new DefaultTypeHandler()); + const first = new Date('2018-05-01T10:00:00.000Z'); + const second = new Date('2018-05-01T12:00:00.000Z'); + expect(handler.compareTo(first, second)).toBeLessThan(0); + expect(handler.compareTo(second, first)).toBeGreaterThan(0); + expect(handler.compareTo(second, second)).toBe(0); + }); + + it ('should compare time values', () => { + const handler = handlerFor('time', new DefaultTypeHandler()); + const first = new Date('1970-01-01T08:00:00.000Z'); + const second = new Date('1970-01-01T09:30:00.000Z'); + expect(handler.compareTo(first, second)).toBeLessThan(0); + expect(handler.compareTo(second, first)).toBeGreaterThan(0); + expect(handler.compareTo(second, second)).toBe(0); + }); + + it ('should compare date values given as strings', () => { + const handler = handlerFor('date', new DefaultTypeHandler()); + expect(handler.compareTo('2018-05-01', '2019-05-01')).toBeLessThan(0); + expect(handler.compareTo('2019-05-01', '2018-05-01')).toBeGreaterThan(0); + expect(handler.compareTo('2019-05-01', '2019-05-01')).toBe(0); + }); + + it ('should sort null values first', () => { + const handler = handlerFor('string', new DefaultTypeHandler()); + expect(handler.compareTo(null as any, 'abc')).toBeLessThan(0); + expect(handler.compareTo('abc', null as any)).toBeGreaterThan(0); + expect(handler.compareTo(null as any, null as any)).toBe(0); + }); + + it ('should compare complex types by their numeric field', () => { + const handler = handlerFor('money', new ManagedDataHandler( + new SpecialFields().setNumericValueField('amount') + )); + expect(handler.compareTo({amount: 10}, {amount: 20})).toBeLessThan(0); + expect(handler.compareTo({amount: 20}, {amount: 10})).toBeGreaterThan(0); + expect(handler.compareTo({amount: 10}, {amount: 10})).toBe(0); + }); + + it ('should sort all primitive types by natural order', () => { + const types: {name:string, values:any[], expected:any[]}[] = [ + {name: 'string', values: ['b', 'a', 'c'], expected: ['a', 'b', 'c']}, + {name: 'number', values: [3, 1, 2], expected: [1, 2, 3]}, + {name: 'boolean', values: [true, false, true], expected: [false, true, true]}, + {name: 'date', values: [new Date('2019-05-01'), new Date('2018-05-01'), new Date('2020-05-01')], expected: [new Date('2018-05-01'), new Date('2019-05-01'), new Date('2020-05-01')]}, + {name: 'date-time', values: [new Date('2019-05-01T12:00:00Z'), new Date('2019-05-01T10:00:00Z'), new Date('2019-05-01T11:00:00Z')], expected: [new Date('2019-05-01T10:00:00Z'), new Date('2019-05-01T11:00:00Z'), new Date('2019-05-01T12:00:00Z')]}, + {name: 'time', values: [new Date('1970-01-01T09:00:00Z'), new Date('1970-01-01T07:00:00Z'), new Date('1970-01-01T08:00:00Z')], expected: [new Date('1970-01-01T07:00:00Z'), new Date('1970-01-01T08:00:00Z'), new Date('1970-01-01T09:00:00Z')]}, + ]; + for (const entry of types) { + const handler = handlerFor(entry.name, new DefaultTypeHandler()); + const sorted = [...entry.values].sort((a, b) => handler.compareTo(a, b)); + expect(sorted).toEqual(entry.expected); + } + }); + +}); + type ToHandleType = { id: string, diff --git a/libs/xt-type/src/handler/xt-type-handler.ts b/libs/xt-type/src/handler/xt-type-handler.ts index 98791342..a919b937 100644 --- a/libs/xt-type/src/handler/xt-type-handler.ts +++ b/libs/xt-type/src/handler/xt-type-handler.ts @@ -32,6 +32,15 @@ export type XtTypeHandler = { isDisplayTemplateSet():boolean; numberToCalculate(value:Type):number | undefined; + /** + * Checks whether values of this type can be sorted. + */ + isSortable ():boolean; + /** + * Compares two values of this type. + */ + compareTo (value1: Type, value2: Type): number; + getOrCreateMappingFrom (fromTypeName: string, registry:XtTypeResolver): MappingHelper | undefined; } @@ -50,6 +59,11 @@ export abstract class AbstractTypeHandler implements XtTypeHandler { */ protected static readonly NONE_MAPPING=new MappingHelper({}); + /** + * Primitive type names that can be sorted by their natural value + */ + protected static readonly SORTABLE_PRIMITIVES=['string','number','boolean','date','date-time','time']; + /** * @param specialFields Optional pre-configured special fields */ @@ -202,6 +216,68 @@ export abstract class AbstractTypeHandler implements XtTypeHandler { return undefined; } + /** + * Checks whether values of this type can be sorted. + * Primitive types (string, number, boolean, date, date-time, time) are always sortable, + * as well as any type configured with a numeric field. + * @returns True if the values of this type can be sorted + */ + isSortable(): boolean { + const typeName = this.type?.type; + if (typeName==null) return false; + if (AbstractTypeHandler.SORTABLE_PRIMITIVES.includes(typeName)) return true; + return this.fields.numericValueField!=null; + } + + /** + * Compares two values of this type. Null values are always sorted first. + * Comparison uses the natural value of the type: lexicographic for strings, + * numeric for numbers and booleans, chronological for dates and times, + * and the configured numeric field for complex types. + * @param value1 The first value to compare + * @param value2 The second value to compare + * @returns A negative number if value1 is smaller, zero if equal, a positive number if value1 is greater + */ + compareTo(value1: Type, value2: Type): number { + if (value1==null) return value2==null ? 0 : -1; + if (value2==null) return 1; + + const typeName = this.type?.type; + switch (typeName) { + case 'string': + return (value1 as string).localeCompare(value2 as string); + case 'number': + return (value1 as number) - (value2 as number); + case 'boolean': + return ((value1 as boolean) ? 1 : 0) - ((value2 as boolean) ? 1 : 0); + case 'date': + case 'date-time': + case 'time': + return this.timeOf(value1) - this.timeOf(value2); + default: { + if (this.fields.numericValueField!=null) { + const n1 = this.numberToCalculate(value1); + const n2 = this.numberToCalculate(value2); + if (n1==null) return n2==null ? 0 : -1; + if (n2==null) return 1; + return n1 - n2; + } + return 0; + } + } + } + + /** + * Extracts a timestamp from a date value, which may be a Date, a date string or an epoch number + * @param value The value to convert to a timestamp + * @returns The timestamp in milliseconds, or 0 if it cannot be parsed + */ + private timeOf(value: Type): number { + if (value instanceof Date) return value.getTime(); + const timeEpoch = Date.parse(String(value)); + return isNaN(timeEpoch) ? 0 : timeEpoch; + } + /** * Parses a date string into a Date object, handling optional timezone annotations * @param dateAsString The date string to parse diff --git a/libs/xt-type/src/resolver/xt-type-resolver.spec.ts b/libs/xt-type/src/resolver/xt-type-resolver.spec.ts index fbadf0b5..0ebcc863 100644 --- a/libs/xt-type/src/resolver/xt-type-resolver.spec.ts +++ b/libs/xt-type/src/resolver/xt-type-resolver.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { xtTypeManager } from '../globals'; +import { DefaultTypeHandler } from '../handler/default/default-type-handler'; import { XtBaseTypeHierarchy } from './xt-type-resolver.ts'; describe('Xt Type Resolver', () => { @@ -61,5 +62,26 @@ describe('Xt Type Resolver', () => { expect((newBookType.children!['author'] as XtBaseTypeHierarchy).children).toBeDefined(); }); + + it ('should keep an alias type handler sortable when used as a child', () => { + const resolver = xtTypeManager(); + resolver.addRootType('rating', 'number', new DefaultTypeHandler()); + resolver.addRootType('movieType', { + title: 'string', + rating: 'rating' + }); + + const root = resolver.findTypeHandler('rating'); + expect(root.typeName).toEqual('number'); + expect(root.handler?.isSortable()).toBe(true); + + const child = resolver.findTypeHandler('movieType', false, 'rating'); + expect(child.typeName).toEqual('number'); + expect(child.handler?.isSortable()).toBe(true); + + // The child node must keep its alias type name so that rendering resolves the right component + const childType = resolver.findType('movieType', 'rating') as XtTypeHierarchy; + expect(childType.type).toEqual('rating'); + }); }) diff --git a/libs/xt-type/src/resolver/xt-type-resolver.ts b/libs/xt-type/src/resolver/xt-type-resolver.ts index f1aa1deb..0d63f7c3 100644 --- a/libs/xt-type/src/resolver/xt-type-resolver.ts +++ b/libs/xt-type/src/resolver/xt-type-resolver.ts @@ -374,9 +374,11 @@ export class XtTypeHierarchyResolver implements XtUpdatableTypeResolver { ret = new UNRESOLVED_TYPE (typeHierarchy); } } else { - // Just create the hierarchy to the primitive type + // Just create the hierarchy to the primitive type. + // The handler is initialized by the root type registration (addRootType). + // For children, the handler may be shared with the root type: re-initializing + // it here would overwrite its type context (e.g. 'rating' alias of 'number'). ret= new XtBaseTypeHierarchy(typeHierarchy, handler); - ret.initHandler(); } } else { diff --git a/plugins/xt-default/projects/default/src/lib/object-set/default-object-set.component.ts b/plugins/xt-default/projects/default/src/lib/object-set/default-object-set.component.ts index cfb66b1e..50773814 100644 --- a/plugins/xt-default/projects/default/src/lib/object-set/default-object-set.component.ts +++ b/plugins/xt-default/projects/default/src/lib/object-set/default-object-set.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, model, output, Signal } from '@angular/core'; import { XtContext, XtRenderSubComponent } from 'xt-components'; import { TableModule } from 'primeng/table'; -import { isTypeReference, XtTypeHierarchy, XtTypeReference } from 'xt-type'; +import { isTypeReference, XtTypeHandler, XtTypeHierarchy, XtTypeReference } from 'xt-type'; import { ObjectSetBase } from './object-set-base'; /** @@ -38,7 +38,7 @@ export class DefaultObjectSetComponent extends ObjectSetBase { return ret; }); - /** Sub-fields that can be sorted. Only primitives (string, number, date, boolean) are sortable for now. */ + /** Sub-fields that can be sorted, based on their type handler (or the primitive type when no handler is registered). */ sortableSubNames = computed>(() => { const sortable = new Set(); for (const subName of this.subNames()) { @@ -79,10 +79,36 @@ export class DefaultObjectSetComponent extends ObjectSetBase { return typeResolver.findPrimitiveType((firstElement as any)[subName])?.type; } - /** Checks whether the sub-field type is a sortable primitive (string, number, date or boolean). */ + /** + * Resolves the type handler of a sub-field, if the sub-field type is registered in the resolver. + * @param subName - The sub-field name to resolve + * @returns The type handler, or undefined when the sub-field type has no registered handler + */ + private subFieldTypeHandler(subName: string): XtTypeHandler | undefined { + const typeResolver = this.resolverService.typeResolver; + const values = this.valueSet(); + const firstElement = (Array.isArray(values) && values.length > 0) ? values[0] : null; + try { + const found = typeResolver.findTypeHandler(this.context().valueType, false, subName, firstElement); + return found.handler; + } catch { + return undefined; + } + } + + /** + * Checks whether the sub-field type can be sorted, using the type handler when one is registered. + * @param subName - The sub-field name to check + * @returns True if the sub-field can be sorted + */ private isSubFieldTypeSortable(subName: string): boolean { + const typeResolver = this.resolverService.typeResolver; const typeName = this.subFieldTypeName(subName); - return (typeName == 'string') || (typeName == 'number') || (typeName == 'date') || (typeName == 'boolean'); + if (typeName == null) return false; + const handler = this.subFieldTypeHandler(subName); + if (handler != null) return handler.isSortable(); + // No handler registered for this type, fall back to the primitive types + return typeResolver.isPrimitiveType(typeName); } /** Builds an XtContext for a specific row element so its sub-fields can be rendered inline. */ diff --git a/plugins/xt-editor/.editorconfig b/plugins/xt-editor/.editorconfig new file mode 100644 index 00000000..f166060d --- /dev/null +++ b/plugins/xt-editor/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/plugins/xt-editor/.gitignore b/plugins/xt-editor/.gitignore new file mode 100644 index 00000000..854acd5f --- /dev/null +++ b/plugins/xt-editor/.gitignore @@ -0,0 +1,44 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/plugins/xt-editor/.postcssrc.json b/plugins/xt-editor/.postcssrc.json new file mode 100644 index 00000000..e663f87e --- /dev/null +++ b/plugins/xt-editor/.postcssrc.json @@ -0,0 +1 @@ +{ "plugins": { "@tailwindcss/postcss": {} }} diff --git a/plugins/xt-editor/.prettierrc b/plugins/xt-editor/.prettierrc new file mode 100644 index 00000000..d6c16d7e --- /dev/null +++ b/plugins/xt-editor/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/plugins/xt-editor/CHANGELOG.md b/plugins/xt-editor/CHANGELOG.md new file mode 100644 index 00000000..4dc68c6f --- /dev/null +++ b/plugins/xt-editor/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/plugins/xt-editor/README.md b/plugins/xt-editor/README.md new file mode 100644 index 00000000..7278eccf --- /dev/null +++ b/plugins/xt-editor/README.md @@ -0,0 +1,13 @@ +![ng-xtend logo](https://dont-code.net/assets/images/logos/logo-xtend-angular-red-small.png) + +# Plugin xt-editor + +This plugin is part of the [ng-xtend framework](https://github.com/dont-code/ng-xtend/blob/main/README.md) + +It enable [xt-components](https://github.com/dont-code/ng-xtend/tree/main/libs/xt-components) to + +- Display and edit rich text notes +![Rich Text Editor](https://dont-code.net/assets/images/screenshots/plugin-default-primitive.png) + +With it you are sure you can display / edit any type within any xt-components application like [xt-host](https://github.com/dont-code/ng-xtend/tree/main/libs/xt-host). + diff --git a/plugins/xt-editor/angular.json b/plugins/xt-editor/angular.json new file mode 100644 index 00000000..0b621070 --- /dev/null +++ b/plugins/xt-editor/angular.json @@ -0,0 +1,132 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "cache": { + "enabled": true + } + }, + "newProjectRoot": "projects", + "projects": { + "editor": { + "projectType": "library", + "root": "projects/editor", + "sourceRoot": "projects/editor/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "configurations": { + "production": { + "tsConfig": "projects/editor/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/editor/tsconfig.lib.json" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "projects/editor/tsconfig.spec.json" + } + } + } + }, + "editor-plugin": { + "projectType": "application", + "schematics": {}, + "root": "projects/editor-plugin", + "sourceRoot": "projects/editor-plugin/src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-architects/native-federation:build", + "options": {}, + "configurations": { + "production": { + "target": "editor-plugin:esbuild:production" + }, + "development": { + "target": "editor-plugin:esbuild:development", + "dev": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-architects/native-federation:build", + "options": { + "target": "editor-plugin:serve-original:development", + "rebuildDelay": 500, + "dev": true, + "cacheExternalArtifacts": false, + "port": 0 + } + }, + "test": { + "builder": "@angular/build:unit-test" + }, + "esbuild": { + "builder": "@angular/build:application", + "options": { + "outputPath": "dist/xt-editor-plugin", + "index": "projects/editor-plugin/src/index.html", + "browser": "projects/editor-plugin/src/main.ts", + "preserveSymlinks": true, + "polyfills": [ + "es-module-shims" + ], + "tsConfig": "projects/editor-plugin/tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "projects/editor-plugin/public" + } + ], + "styles": ["projects/editor-plugin/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "1MB", + "maximumError": "2MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "development" + }, + "serve-original": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "editor-plugin:esbuild:production" + }, + "development": { + "buildTarget": "editor-plugin:esbuild:development" + } + }, + "defaultConfiguration": "development", + "options": { + "port": 4202 + } + } + } + } + } +} diff --git a/plugins/xt-editor/package.json b/plugins/xt-editor/package.json new file mode 100644 index 00000000..52900d46 --- /dev/null +++ b/plugins/xt-editor/package.json @@ -0,0 +1,78 @@ +{ + "name": "xt-plugin-editor", + "version": "1.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build editor", + "build-deploy": "APP=editor-plugin && MAIN_LIB=editor && ng build $MAIN_LIB --configuration=$BUILD && ng build $APP --configuration=$BUILD --output-path=\"../../dist/$STAGE/$APP\" --base-href=\"https://dont-code.net/apps/$STAGE/$APP/\"", + "watch": "ng build --watch --configuration development", + "test": "ng test editor && ng test editor-plugin" + }, + "publishConfig": { + "directory": "dist/xt-plugin-editor", + "linkDirectory": true + }, + "repository": { + "url": "https://github.com/dont-code/ng-xtend.git" + }, + "private": false, + "dependencies": { + "@angular/animations": "^21.2.7", + "@angular/common": "^21.2.7", + "@angular/compiler": "^21.2.7", + "@angular/core": "^21.2.7", + "@angular/forms": "^21.2.7", + "@angular/platform-browser": "^21.2.7", + "@angular/platform-browser-dynamic": "^21.2.7", + "@angular/router": "^21.2.7", + "@angular/cdk": "^21.2.5", + "@types/node": "^24.12.2", + "xt-components": "workspace:^", + "xt-type": "workspace:^", + "xt-store": "workspace:^", + "@ngrx/signals": "^21.1.0", + "rxjs": "^7.8.2", + "primeng": "^21.1.5", + "primeicons": "^7.0.0", + "@primeuix/themes": "^2.0.3", + "tslib": "^2.8.1", + "tailwindcss": "~4.2.2", + "es-module-shims": "^2.8.0", + "@softarc/native-federation-runtime": "^3.3.6", + "prosemirror-model": "^1.25.11", + "prosemirror-transform": "^1.12.0", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.42.2", + "prosemirror-keymap": "^1.2.3", + "prosemirror-commands": "^1.0.0", + "prosemirror-schema-basic": "^1.2.4", + "prosemirror-markdown": "^1.13.5", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-menu": "^1.3.2", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-dropcursor": "^1.8.3", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history-v2": "^1.1.3" + }, + "devDependencies": { + "@angular-architects/native-federation": "^21.2.3", + "@primeuix/utils": "^0.7.1", + "@primeuix/styled": "^0.7.2", + "@angular-devkit/build-angular": "^21.2.6", + "@angular/cli": "^21.2.6", + "@angular/build": "^21.2.6", + "@angular/compiler-cli": "^21.2.7", + "@types/node": "^24.12.2", + "ng-packagr": "^21.2.2", + "typescript": "^5.9.3", + "jsdom": "^29.0.1", + "vitest": "^4.1.2", + "happy-dom": "^20.8.9", + "@tailwindcss/postcss": "~4.2.2", + "postcss": "~8.5.6", + "@prosemirror/buildhelper": "^0.1.5" + } +} + diff --git a/plugins/xt-editor/projects/editor-plugin/federation.config.js b/plugins/xt-editor/projects/editor-plugin/federation.config.js new file mode 100644 index 00000000..892b1656 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/federation.config.js @@ -0,0 +1,34 @@ +const { withNativeFederation, shareAll, share } = require('@angular-architects/native-federation/config'); + +module.exports = withNativeFederation({ + + name: 'editor-plugin', + + exposes: { + './EditorComponent': './projects/editor/src/lib/editor/editor.component.ts', + './Register': './projects/editor/src/lib/register.ts' + }, + + shared: { + ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }), +}, + + features: { + ignoreUnusedDeps:true + }, + skip: [ + 'rxjs/ajax', + 'rxjs/fetch', + 'rxjs/testing', + 'rxjs/webSocket', + // Add further packages you don't need at runtime + /^@primeuix\//, + 'chart.js/auto', + 'primeng/chart', + 'primeicons' + ] + + // Please read our FAQ about sharing libs: + // https://shorturl.at/jmzH0 + +}); diff --git a/plugins/xt-editor/projects/editor-plugin/public/favicon.ico b/plugins/xt-editor/projects/editor-plugin/public/favicon.ico new file mode 100644 index 00000000..57614f9c Binary files /dev/null and b/plugins/xt-editor/projects/editor-plugin/public/favicon.ico differ diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/app.component.css b/plugins/xt-editor/projects/editor-plugin/src/app/app.component.css new file mode 100644 index 00000000..e69de29b diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/app.component.html b/plugins/xt-editor/projects/editor-plugin/src/app/app.component.html new file mode 100644 index 00000000..80570948 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/app/app.component.html @@ -0,0 +1,3 @@ +

Editor Plugin Testing app

+ + diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/app.component.spec.ts b/plugins/xt-editor/projects/editor-plugin/src/app/app.component.spec.ts new file mode 100644 index 00000000..0aa0d554 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/app/app.component.spec.ts @@ -0,0 +1,34 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +describe('Editor Tester', () => { + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + providers: [provideNoopAnimations(),provideZonelessChangeDetection()] + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have the 'WebTester' title`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('WebTester'); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Web Plugin Testing app'); + }); +}); diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/app.component.ts b/plugins/xt-editor/projects/editor-plugin/src/app/app.component.ts new file mode 100644 index 00000000..d5b7767a --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/app/app.component.ts @@ -0,0 +1,21 @@ +import { Component, inject } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { XtResolverService } from 'xt-components'; +import { registerEditorPlugin } from '../../../editor/src/lib/register'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet], + templateUrl: './app.component.html', + styleUrl: './app.component.css' +}) +export class AppComponent { + title = 'EditorTester'; + + protected resolverService = inject (XtResolverService); + + constructor () { + registerEditorPlugin(this.resolverService); + } + +} diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/app.config.ts b/plugins/xt-editor/projects/editor-plugin/src/app/app.config.ts new file mode 100644 index 00000000..58547d2b --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/app/app.config.ts @@ -0,0 +1,22 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { providePrimeNG } from 'primeng/config'; +import Aura from '@primeuix/themes/aura'; +import { provideHttpClient } from '@angular/common/http'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZonelessChangeDetection(), + provideHttpClient(), + provideAnimationsAsync(), + providePrimeNG({ + theme: { + preset: Aura + } + }), + provideRouter(routes)] +}; diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/app.routes.ts b/plugins/xt-editor/projects/editor-plugin/src/app/app.routes.ts new file mode 100644 index 00000000..531b53c6 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/app/app.routes.ts @@ -0,0 +1,6 @@ +import { Routes } from '@angular/router'; +import { EditorTestComponent } from './editor-test-component/editor-test.component'; + +export const routes: Routes = [{ + path:'', component:EditorTestComponent +}]; diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.css b/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.css new file mode 100644 index 00000000..e69de29b diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.html b/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.html new file mode 100644 index 00000000..f7a13145 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.html @@ -0,0 +1,31 @@ +

Testing Editor

+ + +
+ + In memory only + + +
+
+
+ + +
+ +
+
+ +
+ +
+ Form Value is {{mainForm.value | json}}, Form is {{mainForm.pristine?"pristine":"Dirty"}} + +
+ + + + + + +
diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.spec.ts b/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.spec.ts new file mode 100644 index 00000000..09b0f6c8 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.spec.ts @@ -0,0 +1,32 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WebTestComponent } from './web-test.component'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { provideZonelessChangeDetection } from '@angular/core'; +import { registerWebPlugin } from '../../../../web/src/lib/register'; +import { StoreTestHelper, XtResolverService } from 'xt-components'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { provideHttpClient } from '@angular/common/http'; + +describe('TestComponent', () => { + let component: WebTestComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WebTestComponent], + providers: [provideNoopAnimations(), provideZonelessChangeDetection(), provideHttpClient()] + }) + .compileComponents(); + + StoreTestHelper.ensureTestProviderOnly(); + registerWebPlugin(TestBed.inject(XtResolverService)); + fixture = TestBed.createComponent(WebTestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.ts b/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.ts new file mode 100644 index 00000000..27a22114 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/app/editor-test-component/editor-test.component.ts @@ -0,0 +1,103 @@ +import { Component, effect, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { AutoComplete, AutoCompleteSelectEvent } from 'primeng/autocomplete'; +import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { JsonPipe } from '@angular/common'; +import { Subscription } from 'rxjs'; +import { attachToFormGroup, XtRenderComponent, XtResolverService } from 'xt-components'; +import { Panel } from 'primeng/panel'; +import { Checkbox } from 'primeng/checkbox'; +import { XtStoreManagerService, XtApiStoreProvider, XtMemoryStoreProvider } from 'xt-store'; + +@Component({ + selector: 'app-editor-test', + imports: [ + AutoComplete, + FormsModule, + ReactiveFormsModule, + JsonPipe, XtRenderComponent, Panel, Checkbox + ], + templateUrl: './editor-test.component.html', + styleUrl: './editor-test.component.css' +}) +export class EditorTestComponent implements OnInit, OnDestroy { + + protected builder = inject(FormBuilder); + mainForm :FormGroup =this.builder.group ({ }); + + protected resolver = inject (XtResolverService); + + selectedType= signal('link'); + + docUrl = signal(null); + storeInMemory = signal(true); + + value = signal('https://ng-xtend.dev'); + + protected storeMgr= inject(XtStoreManagerService); + protected apiProvider = inject (XtApiStoreProvider); + + protected subscriptions= new Subscription(); + + constructor() { + + } + + listOfSimpleTypes() { + return ['image','link', 'rating']; + } + + typeSwitch($event: AutoCompleteSelectEvent) { + attachToFormGroup(this.mainForm, 'TestType', null, $event.value, this.resolver.typeResolver); + this.selectedType.set($event.value); +// this.mainForm.setValue(); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + ngOnInit(): void { + attachToFormGroup(this.mainForm, 'TestType', this.value(), this.selectedType(), this.resolver.typeResolver); + + this.listenToValueChanges(); + } + + protected listenToValueChanges() { + // this.subscriptions.unsubscribe(); + this.subscriptions.add(this.mainForm.valueChanges.subscribe({ + next: newValue => { + if (newValue.TestType !== undefined) + this.value.set(newValue.TestType); + } + })); + } + + updateStore() { + if (this.storeInMemory()) { + this.storeMgr.setDefaultStoreProvider(new XtMemoryStoreProvider()); + }else { + this.apiProvider.docUrl=this.docUrl()??''; + this.storeMgr.setDefaultStoreProvider(this.apiProvider); + } + } + + listofDocUrls():string[] { + return [ + 'https://test.dont-code.net/demo/documents', + 'https://collinfr.net/dont-code/documents', + 'http://localhost:8084/documents']; + } + + docUrlChanged($event: string) { + if (($event==null)||($event.length==0)){ + this.storeInMemory.set(true); + } else this.storeInMemory.set(false); + this.docUrl.set($event); + this.updateStore(); + } + + inMemoryChanged($event: boolean) { + this.storeInMemory.set($event); + this.updateStore(); + } +} diff --git a/plugins/xt-editor/projects/editor-plugin/src/bootstrap.ts b/plugins/xt-editor/projects/editor-plugin/src/bootstrap.ts new file mode 100644 index 00000000..35b00f34 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/bootstrap.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/plugins/xt-editor/projects/editor-plugin/src/index.html b/plugins/xt-editor/projects/editor-plugin/src/index.html new file mode 100644 index 00000000..d31aea78 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/index.html @@ -0,0 +1,13 @@ + + + + + Editor Tester + + + + + + + + diff --git a/plugins/xt-editor/projects/editor-plugin/src/main.ts b/plugins/xt-editor/projects/editor-plugin/src/main.ts new file mode 100644 index 00000000..ed18d478 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/main.ts @@ -0,0 +1,6 @@ +import { initFederation } from '@angular-architects/native-federation'; + +initFederation() + .catch(err => console.error(err)) + .then(_ => import('./bootstrap')) + .catch(err => console.error(err)); diff --git a/plugins/xt-editor/projects/editor-plugin/src/styles.css b/plugins/xt-editor/projects/editor-plugin/src/styles.css new file mode 100644 index 00000000..8b1d69c2 --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/src/styles.css @@ -0,0 +1,12 @@ +/* You can add global styles to this file, and also import other style files */ +@import "tailwindcss"; +@import "primeicons/primeicons.css"; + +html { + height: 100%; +} +body { + min-height: 100%; + margin: 0; + padding: 0; +} diff --git a/plugins/xt-editor/projects/editor-plugin/tsconfig.app.json b/plugins/xt-editor/projects/editor-plugin/tsconfig.app.json new file mode 100644 index 00000000..d846f25e --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/tsconfig.app.json @@ -0,0 +1,11 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/app", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/plugins/xt-editor/projects/editor-plugin/tsconfig.federation.json b/plugins/xt-editor/projects/editor-plugin/tsconfig.federation.json new file mode 100644 index 00000000..5f69876e --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/tsconfig.federation.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} \ No newline at end of file diff --git a/plugins/xt-editor/projects/editor-plugin/tsconfig.spec.json b/plugins/xt-editor/projects/editor-plugin/tsconfig.spec.json new file mode 100644 index 00000000..48fcc2fd --- /dev/null +++ b/plugins/xt-editor/projects/editor-plugin/tsconfig.spec.json @@ -0,0 +1,10 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] +} diff --git a/plugins/xt-editor/projects/editor/README.md b/plugins/xt-editor/projects/editor/README.md new file mode 100644 index 00000000..642dccb2 --- /dev/null +++ b/plugins/xt-editor/projects/editor/README.md @@ -0,0 +1,13 @@ +![ng-xtend logo](https://dont-code.net/assets/images/logos/logo-xtend-angular-red-small.png) + +# Plugin xt-editor + +This plugin is part of the [ng-xtend framework](https://github.com/dont-code/ng-xtend/blob/main/README.md) + +It enable [xt-components](https://github.com/dont-code/ng-xtend/tree/main/libs/xt-components) to + +- Display and edit rich text notes + ![Rich Text Editor](https://dont-code.net/assets/images/screenshots/plugin-default-primitive.png) + +With it you are sure you can display / edit any type within any xt-components application like [xt-host](https://github.com/dont-code/ng-xtend/tree/main/libs/xt-host). + diff --git a/plugins/xt-editor/projects/editor/ng-package.json b/plugins/xt-editor/projects/editor/ng-package.json new file mode 100644 index 00000000..1571f5b6 --- /dev/null +++ b/plugins/xt-editor/projects/editor/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/xt-plugin-editor", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/plugins/xt-editor/projects/editor/package.json b/plugins/xt-editor/projects/editor/package.json new file mode 100644 index 00000000..8f629559 --- /dev/null +++ b/plugins/xt-editor/projects/editor/package.json @@ -0,0 +1,25 @@ +{ + "name": "xt-plugin-editor", + "version": "1.0.0", + "repository": { + "url": "https://github.com/dont-code/ng-xtend.git" + }, + "peerDependencies": { + "@angular/animations": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/compiler": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/forms": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-browser-dynamic": "^21.0.0", + "@angular/router": "^21.0.0", + "xt-components": "^1.0.0", + "rxjs": "^7.8.2", + "primeng": "^21.0.0", + "primeicons": "^7.0.0" + }, + "dependencies": { + "tslib": "^2.8.1" + }, + "sideEffects": false +} diff --git a/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.css b/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.css new file mode 100644 index 00000000..e69de29b diff --git a/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.html b/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.html new file mode 100644 index 00000000..c1080521 --- /dev/null +++ b/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.html @@ -0,0 +1,7 @@ +@if (isInForm() ) { + + + +} @else if (displayValue()!=null) { + ${{ displayValue() }} +} diff --git a/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.spec.ts b/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.spec.ts new file mode 100644 index 00000000..a5cda7c2 --- /dev/null +++ b/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.spec.ts @@ -0,0 +1,49 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { provideZonelessChangeDetection } from '@angular/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { HostTestFormComponent, XtBaseContext } from 'xt-components'; +import { EditorComponent } from './editor.component'; +import { By } from '@angular/platform-browser'; + +describe('EditorComponent', () => { + let component: EditorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EditorComponent], + providers: [provideNoopAnimations(), provideZonelessChangeDetection()] + + }) + .compileComponents(); + }); + + it('should create', () => { + fixture = TestBed.createComponent(EditorComponent); + const context=new XtBaseContext('FULL_VIEW'); + context.setDisplayValue("My Text to display"); + fixture.componentRef.setInput('context', context); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should show editor in form', () => { + const hostFixture = TestBed.createComponent(HostTestFormComponent); + hostFixture.componentRef.setInput('type', EditorComponent); + hostFixture.componentRef.setInput('formDescription', { + testText: 'My text to edit' + }); + hostFixture.componentRef.setInput('controlName', 'testText'); + const host = hostFixture.componentInstance; + expect(host).toBeTruthy(); + hostFixture.detectChanges(); + + const componentDebug = hostFixture.debugElement.query(By.directive(EditorComponent)); + component=componentDebug.componentInstance; + expect(component).toBeTruthy(); + }); + +}); diff --git a/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.ts b/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.ts new file mode 100644 index 00000000..e704fa1e --- /dev/null +++ b/plugins/xt-editor/projects/editor/src/lib/editor/editor.component.ts @@ -0,0 +1,56 @@ +import { ChangeDetectionStrategy, Component, ElementRef, Injector, Renderer2, ViewChild } from '@angular/core'; +import { XtSimpleComponent } from 'xt-components'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { EditorState } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { DOMParser, Schema } from 'prosemirror-model'; +import { schema } from 'prosemirror-schema-basic'; +import { addListNodes } from 'prosemirror-schema-list'; +import { basicSetup } from '../prose-mirror/basic-setup'; + +@Component({ + selector: 'xt-editor', + imports: [ + ReactiveFormsModule, + FormsModule + ], + templateUrl: './editor.component.html', + styleUrl: './editor.component.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditorComponent extends XtSimpleComponent{ + + @ViewChild('proseMirror', { static: false }) private proseMirror: ElementRef | undefined; + //private proseMirror: ElementRef | undefined; + + constructor( private renderer: Renderer2, private injector: Injector, private elementRef: ElementRef, + ) { + super(); + } +// Mix the nodes from prosemirror-schema-list into the basic schema to +// create a schema with list support. + mySchema = new Schema({ + nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"), + marks: schema.spec.marks + }); + + protected view: EditorView|null=null; + + override ngOnInit(): void { + super.ngOnInit(); + if( this.proseMirror!=null) { + this.view = new EditorView(null, { + state: EditorState.create({ + doc: DOMParser.fromSchema(this.mySchema).parse(this.context().value()), + plugins: basicSetup({schema: this.mySchema}) + }) + }) + + this.renderer.appendChild(this.proseMirror.nativeElement, this.view.dom); + } + /* this.editor.valueChanges.pipe(takeUntil(this.unsubscribe)).subscribe((jsonDoc) => { + this.handleChange(jsonDoc); + });*/ + } + +} diff --git a/plugins/xt-editor/projects/editor/src/lib/prose-mirror/basic-setup.ts b/plugins/xt-editor/projects/editor/src/lib/prose-mirror/basic-setup.ts new file mode 100644 index 00000000..07f26be1 --- /dev/null +++ b/plugins/xt-editor/projects/editor/src/lib/prose-mirror/basic-setup.ts @@ -0,0 +1,654 @@ + + + +/// Given a blockquote node type, returns an input rule that turns `"> "` +/// at the start of a textblock into a blockquote. + +import { + wrapItem, blockTypeItem, Dropdown, DropdownSubmenu, joinUpItem, liftItem, + selectParentNodeItem, undoItem, redoItem, icons, MenuItem, MenuElement, MenuItemSpec, menuBar +} from 'prosemirror-menu'; +import {NodeSelection, EditorState, Command} from "prosemirror-state" +import {Schema, NodeType, MarkType} from "prosemirror-model" + +import {Attrs} from "prosemirror-model" + +const prefix = "ProseMirror-prompt" + +import { + wrapIn, setBlockType, chainCommands, toggleMark, exitCode, + joinUp, joinDown, lift, selectParentNode, baseKeymap +} from 'prosemirror-commands'; +import {wrapInList, splitListItem, liftListItem, sinkListItem} from "prosemirror-schema-list" +import { undo, redo, history } from 'prosemirror-history'; +import { + ellipsis, emDash, + inputRules, + smartQuotes, + textblockTypeInputRule, + undoInputRule, + wrappingInputRule +} from 'prosemirror-inputrules'; +import { keymap } from 'prosemirror-keymap'; +import { dropCursor } from 'prosemirror-dropcursor'; +import { gapCursor } from 'prosemirror-gapcursor'; + +const mac = typeof navigator != "undefined" ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) : false + +/// Inspect the given schema looking for marks and nodes from the +/// basic schema, and if found, add key bindings related to them. +/// This will add: +/// +/// * **Mod-b** for toggling [strong](#schema-basic.StrongMark) +/// * **Mod-i** for toggling [emphasis](#schema-basic.EmMark) +/// * **Mod-`** for toggling [code font](#schema-basic.CodeMark) +/// * **Ctrl-Shift-0** for making the current textblock a paragraph +/// * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current +/// textblock a heading of the corresponding level +/// * **Ctrl-Shift-Backslash** to make the current textblock a code block +/// * **Ctrl-Shift-8** to wrap the selection in an ordered list +/// * **Ctrl-Shift-9** to wrap the selection in a bullet list +/// * **Ctrl->** to wrap the selection in a block quote +/// * **Enter** to split a non-empty textblock in a list item while at +/// the same time splitting the list item +/// * **Mod-Enter** to insert a hard break +/// * **Mod-_** to insert a horizontal rule +/// * **Backspace** to undo an input rule +/// * **Alt-ArrowUp** to `joinUp` +/// * **Alt-ArrowDown** to `joinDown` +/// * **Mod-BracketLeft** to `lift` +/// * **Escape** to `selectParentNode` +/// +/// You can suppress or map these bindings by passing a `mapKeys` +/// argument, which maps key names (say `"Mod-B"` to either `false`, to +/// remove the binding, or a new key name string. +export function buildKeymap(schema: Schema, mapKeys?: {[key: string]: false | string}) { + let keys: {[key: string]: Command} = {}, type + function bind(key: string, cmd: Command) { + if (mapKeys) { + let mapped = mapKeys[key] + if (mapped === false) return + if (mapped) key = mapped + } + keys[key] = cmd + } + + bind("Mod-z", undo) + bind("Shift-Mod-z", redo) + bind("Backspace", undoInputRule) + if (!mac) bind("Mod-y", redo) + + bind("Alt-ArrowUp", joinUp) + bind("Alt-ArrowDown", joinDown) + bind("Mod-BracketLeft", lift) + bind("Escape", selectParentNode) + + if (type == schema.marks["strong"]) { + bind("Mod-b", toggleMark(type)) + bind("Mod-B", toggleMark(type)) + } + if (type == schema.marks["em"]) { + bind("Mod-i", toggleMark(type)) + bind("Mod-I", toggleMark(type)) + } + if (type == schema.marks["code"]) + bind("Mod-`", toggleMark(type)) + + if (type == schema.nodes["bullet_list"]) + bind("Shift-Ctrl-8", wrapInList(type)) + if (type == schema.nodes["ordered_list"]) + bind("Shift-Ctrl-9", wrapInList(type)) + if (type == schema.nodes["blockquote"]) + bind("Ctrl->", wrapIn(type)) + if (type == schema.nodes["hard_break"]) { + let br = type as any, cmd = chainCommands(exitCode, (state, dispatch) => { + if (dispatch) dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView()) + return true + }) + bind("Mod-Enter", cmd) + bind("Shift-Enter", cmd) + if (mac) bind("Ctrl-Enter", cmd) + } + if (type == schema.nodes["list_item"]) { + bind("Enter", splitListItem(type)) + bind("Mod-[", liftListItem(type)) + bind("Mod-]", sinkListItem(type)) + } + if (type == schema.nodes["paragraph"]) + bind("Shift-Ctrl-0", setBlockType(type)) + if (type == schema.nodes["code_block"]) + bind("Shift-Ctrl-\\", setBlockType(type)) + if (type == schema.nodes["heading"]) + for (let i = 1; i <= 6; i++) bind("Shift-Ctrl-" + i, setBlockType(type, {level: i})) + if (type == schema.nodes["horizontal_rule"]) { + let hr = type as any + bind("Mod-_", (state, dispatch) => { + if (dispatch) dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView()) + return true + }) + } + + return keys +} + + +export function openPrompt(options: { + title: string, + fields: {[name: string]: Field}, + callback: (attrs: Attrs) => void +}) { + let wrapper = document.body.appendChild(document.createElement("div")) + wrapper.className = prefix + + let mouseOutside = (e: MouseEvent) => { if (!wrapper.contains(e.target as HTMLElement)) close() } + setTimeout(() => window.addEventListener("mousedown", mouseOutside), 50) + let close = () => { + window.removeEventListener("mousedown", mouseOutside) + if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper) + } + + let domFields: HTMLElement[] = [] + for (let name in options.fields) domFields.push(options.fields[name].render()) + + let submitButton = document.createElement("button") + submitButton.type = "submit" + submitButton.className = prefix + "-submit" + submitButton.textContent = "OK" + let cancelButton = document.createElement("button") + cancelButton.type = "button" + cancelButton.className = prefix + "-cancel" + cancelButton.textContent = "Cancel" + cancelButton.addEventListener("click", close) + + let form = wrapper.appendChild(document.createElement("form")) + if (options.title) form.appendChild(document.createElement("h5")).textContent = options.title + domFields.forEach(field => { + form.appendChild(document.createElement("div")).appendChild(field) + }) + let buttons = form.appendChild(document.createElement("div")) + buttons.className = prefix + "-buttons" + buttons.appendChild(submitButton) + buttons.appendChild(document.createTextNode(" ")) + buttons.appendChild(cancelButton) + + let box = wrapper.getBoundingClientRect() + wrapper.style.top = ((window.innerHeight - box.height) / 2) + "px" + wrapper.style.left = ((window.innerWidth - box.width) / 2) + "px" + + let submit = () => { + let params = getValues(options.fields, domFields) + if (params) { + close() + options.callback(params) + } + } + + form.addEventListener("submit", e => { + e.preventDefault() + submit() + }) + + form.addEventListener("keydown", e => { + if (e.keyCode == 27) { + e.preventDefault() + close() + } else if (e.keyCode == 13 && !(e.ctrlKey || e.metaKey || e.shiftKey)) { + e.preventDefault() + submit() + } else if (e.keyCode == 9) { + window.setTimeout(() => { + if (!wrapper.contains(document.activeElement)) close() + }, 500) + } + }) + + let input = form.elements[0] as HTMLElement + if (input) input.focus() +} + +function getValues(fields: {[name: string]: Field}, domFields: readonly HTMLElement[]) { + let result = Object.create(null), i = 0 + for (let name in fields) { + let field = fields[name], dom = domFields[i++] + let value = field.read(dom), bad = field.validate(value) + if (bad) { + reportInvalid(dom, bad) + return null + } + result[name] = field.clean(value) + } + return result +} + +function reportInvalid(dom: HTMLElement, message: string) { + // FIXME this is awful and needs a lot more work + let parent = dom.parentNode! + let msg = parent.appendChild(document.createElement("div")) + msg.style.left = (dom.offsetLeft + dom.offsetWidth + 2) + "px" + msg.style.top = (dom.offsetTop - 5) + "px" + msg.className = "ProseMirror-invalid" + msg.textContent = message + setTimeout(() => parent.removeChild(msg), 1500) +} + +/// The type of field that `openPrompt` expects to be passed to it. +export abstract class Field { + /// Create a field with the given options. Options support by all + /// field types are: + constructor( + /// @internal + readonly options: { + /// The starting value for the field. + value?: any + + /// The label for the field. + label: string + + /// Whether the field is required. + required?: boolean + + /// A function to validate the given value. Should return an + /// error message if it is not valid. + validate?: (value: any) => string | null + + /// A cleanup function for field values. + clean?: (value: any) => any + } + ) {} + + /// Render the field to the DOM. Should be implemented by all subclasses. + abstract render(): HTMLElement + + /// Read the field's value from its DOM node. + read(dom: HTMLElement) { return (dom as any).value } + + /// A field-type-specific validation function. + validateType(value: any): string | null { return null } + + /// @internal + validate(value: any): string | null { + if (!value && this.options.required) + return "Required field" + return this.validateType(value) || (this.options.validate ? this.options.validate(value) : null) + } + + clean(value: any): any { + return this.options.clean ? this.options.clean(value) : value + } +} + +/// A field class for single-line text fields. +export class TextField extends Field { + render() { + let input = document.createElement("input") + input.type = "text" + input.placeholder = this.options.label + input.value = this.options.value || "" + input.autocomplete = "off" + return input + } +} + + +/// A field class for dropdown fields based on a plain ` + + + + @if (store?.loading()) {