├── projects ├── angular-editor-app │ ├── src │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── app │ │ │ ├── app.component.scss │ │ │ ├── app.module.ts │ │ │ ├── app.component.spec.ts │ │ │ ├── app.component.html │ │ │ └── app.component.ts │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── favicon.ico │ │ ├── styles.scss │ │ ├── index.html │ │ ├── main.ts │ │ ├── test.ts │ │ └── polyfills.ts │ ├── tsconfig.app.json │ ├── e2e │ │ ├── tsconfig.json │ │ ├── src │ │ │ ├── app.po.ts │ │ │ └── app.e2e-spec.ts │ │ └── protractor.conf.js │ ├── tsconfig.spec.json │ ├── .browserslistrc │ ├── karma.conf.js │ └── .eslintrc.json └── angular-editor │ ├── src │ ├── lib │ │ ├── styles.scss │ │ ├── ae-toolbar-set │ │ │ ├── ae-toolbar-set.component.html │ │ │ ├── ae-toolbar-set.component.scss │ │ │ ├── ae-toolbar-set.component.ts │ │ │ └── ae-toolbar-set.component.spec.ts │ │ ├── utils.ts │ │ ├── ae-button │ │ │ ├── ae-button.component.html │ │ │ ├── ae-button.component.ts │ │ │ ├── ae-button.component.spec.ts │ │ │ └── ae-button.component.scss │ │ ├── angular-editor.service.spec.ts │ │ ├── ae-select │ │ │ ├── ae-select.component.html │ │ │ ├── ae-select.component.scss │ │ │ ├── ae-select.component.spec.ts │ │ │ └── ae-select.component.ts │ │ ├── angular-editor.module.ts │ │ ├── editor │ │ │ ├── angular-editor.component.spec.ts │ │ │ ├── angular-editor.component.html │ │ │ ├── angular-editor.component.scss │ │ │ └── angular-editor.component.ts │ │ ├── config.ts │ │ ├── ae-toolbar │ │ │ ├── ae-toolbar.component.scss │ │ │ ├── ae-toolbar.component.html │ │ │ └── ae-toolbar.component.ts │ │ └── angular-editor.service.ts │ ├── public-api.ts │ └── test.ts │ ├── tsconfig.lib.prod.json │ ├── ng-package.json │ ├── tsconfig.spec.json │ ├── tsconfig.lib.json │ ├── package.json │ ├── themes │ └── default.scss │ ├── .eslintrc.json │ ├── karma.conf.js │ └── assets │ └── icons │ └── icons.svg ├── docs └── angular-editor-logo.png ├── .editorconfig ├── tsconfig.json ├── .github ├── CODEOWNERS └── workflows │ ├── npm-publish.yml │ └── README.md ├── LICENSE ├── .eslintrc.json ├── .gitignore ├── package.json ├── CONTRIBUTING.md ├── CHANGELOG.md ├── angular.json └── README.md /projects/angular-editor-app/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/styles.scss: -------------------------------------------------------------------------------- 1 | a { 2 | cursor: pointer; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-toolbar-set/ae-toolbar-set.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/angular-editor-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolkov/angular-editor/HEAD/docs/angular-editor-logo.png -------------------------------------------------------------------------------- /projects/angular-editor-app/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolkov/angular-editor/HEAD/projects/angular-editor-app/src/favicon.ico -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function isDefined(value: any) { 2 | return value !== undefined && value !== null; 3 | } 4 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | .container { 4 | max-width: 720px; 5 | margin-right: auto; 6 | margin-left: auto; 7 | } 8 | -------------------------------------------------------------------------------- /projects/angular-editor/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /projects/angular-editor/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/angular-editor", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | }, 7 | "assets": [ 8 | "./assets", 9 | "./themes" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /projects/angular-editor-app/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts", 9 | "src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /projects/angular-editor-app/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es2018", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /projects/angular-editor-app/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText() { 9 | return element(by.css('app-root h1')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /projects/angular-editor/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts" 12 | ], 13 | "include": [ 14 | "**/*.spec.ts", 15 | "**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-button/ae-button.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AngularEditorApp 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /projects/angular-editor-app/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-toolbar-set/ae-toolbar-set.component.scss: -------------------------------------------------------------------------------- 1 | @import "../styles"; 2 | 3 | :host { 4 | &.angular-editor-toolbar-set { 5 | display: flex; 6 | gap: 1px; 7 | width: fit-content; 8 | vertical-align: baseline; 9 | } 10 | } 11 | 12 | .angular-editor-toolbar-set:not([style*="display:none"]):not([style*="display: none"]){ 13 | //display: inline-block; 14 | } 15 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /projects/angular-editor/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of angular-editor 3 | */ 4 | 5 | export * from './lib/angular-editor.service'; 6 | export * from './lib/editor/angular-editor.component'; 7 | export * from './lib/ae-button/ae-button.component'; 8 | export * from './lib/ae-toolbar-set/ae-toolbar-set.component'; 9 | export * from './lib/ae-select/ae-select.component'; 10 | export * from './lib/ae-toolbar/ae-toolbar.component'; 11 | export * from './lib/angular-editor.module'; 12 | export { AngularEditorConfig, CustomClass } from './lib/config'; 13 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-toolbar-set/ae-toolbar-set.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, ViewEncapsulation} from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'ae-toolbar-set, [aeToolbarSet]', 5 | templateUrl: './ae-toolbar-set.component.html', 6 | styleUrls: ['./ae-toolbar-set.component.scss'], 7 | //encapsulation: ViewEncapsulation.None, 8 | host: { 9 | 'class': 'angular-editor-toolbar-set' 10 | }, 11 | standalone: false 12 | }) 13 | export class AeToolbarSetComponent { 14 | 15 | constructor() { 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /projects/angular-editor/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declarationMap": true, 6 | "declaration": true, 7 | "inlineSources": true, 8 | "types": [] 9 | }, 10 | "angularCompilerOptions": { 11 | "skipTemplateCodegen": true, 12 | "strictMetadataEmit": true, 13 | "fullTemplateTypeCheck": true, 14 | "strictInjectionParameters": true, 15 | "enableResourceInlining": true 16 | }, 17 | "exclude": [ 18 | "src/test.ts", 19 | "**/*.spec.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /projects/angular-editor-app/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | # Googlebot uses an older version of Chrome 9 | # For additional information see: https://developers.google.com/search/docs/guides/rendering 10 | 11 | > 0.5% 12 | last 2 versions 13 | Firefox ESR 14 | not dead 15 | not IE 9-11 -------------------------------------------------------------------------------- /projects/angular-editor/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js'; 4 | import 'zone.js/testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { 7 | BrowserDynamicTestingModule, 8 | platformBrowserDynamicTesting 9 | } from '@angular/platform-browser-dynamic/testing'; 10 | 11 | // First, initialize the Angular testing environment. 12 | getTestBed().initTestEnvironment( 13 | BrowserDynamicTestingModule, 14 | platformBrowserDynamicTesting(), { 15 | teardown: { destroyAfterEach: true } 16 | } 17 | ); 18 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/angular-editor.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {inject, TestBed} from '@angular/core/testing'; 2 | 3 | import {AngularEditorService} from './angular-editor.service'; 4 | import {HttpClientModule} from '@angular/common/http'; 5 | 6 | describe('AngularEditorService', () => { 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({ 9 | imports: [ HttpClientModule ], 10 | providers: [AngularEditorService] 11 | }); 12 | }); 13 | 14 | it('should be created', inject([AngularEditorService], (service: AngularEditorService) => { 15 | expect(service).toBeTruthy(); 16 | })); 17 | }); 18 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-button/ae-button.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input, ViewEncapsulation} from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'ae-button, button[aeButton]', 5 | templateUrl: './ae-button.component.html', 6 | styleUrls: ['./ae-button.component.scss'], 7 | //encapsulation: ViewEncapsulation.None, 8 | host: { 9 | 'class': 'angular-editor-button', 10 | '[tabIndex]': '-1', 11 | '[type]': '"button"', 12 | }, 13 | standalone: false 14 | }) 15 | export class AeButtonComponent { 16 | 17 | @Input() iconName = ''; 18 | 19 | constructor() { 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { AppComponent } from './app.component'; 5 | import {FormsModule, ReactiveFormsModule} from '@angular/forms'; 6 | import {HttpClientModule} from '@angular/common/http'; 7 | import {AngularEditorModule} from '../../../angular-editor/src/lib/angular-editor.module'; 8 | 9 | 10 | @NgModule({ 11 | declarations: [ 12 | AppComponent 13 | ], 14 | imports: [ 15 | BrowserModule, 16 | AngularEditorModule, 17 | HttpClientModule, 18 | FormsModule, 19 | ReactiveFormsModule, 20 | ], 21 | providers: [], 22 | bootstrap: [AppComponent] 23 | }) 24 | export class AppModule { } 25 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /projects/angular-editor-app/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('Welcome to angular-editor-app!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting(), { 16 | teardown: { destroyAfterEach: false } 17 | } 18 | ); 19 | // Then we find all the tests. 20 | const context = require.context('./', true, /\.spec\.ts$/); 21 | // And load the modules. 22 | context.keys().map(context); 23 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-button/ae-button.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AeButtonComponent } from './ae-button.component'; 4 | 5 | describe('AeButtonComponent', () => { 6 | let component: AeButtonComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ AeButtonComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AeButtonComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-toolbar-set/ae-toolbar-set.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AeToolbarSetComponent } from './ae-toolbar-set.component'; 4 | 5 | describe('AeToolbarSetComponent', () => { 6 | let component: AeToolbarSetComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ AeToolbarSetComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AeToolbarSetComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "module": "es2020", 9 | "moduleResolution": "bundler", 10 | "experimentalDecorators": true, 11 | "importHelpers": true, 12 | "target": "es2017", 13 | "typeRoots": [ 14 | "node_modules/@types" 15 | ], 16 | "lib": [ 17 | "es2020", 18 | "dom" 19 | ], 20 | "paths": { 21 | "angular-editor": [ 22 | "dist/angular-editor" 23 | ], 24 | "angular-editor/*": [ 25 | "dist/angular-editor/*" 26 | ], 27 | "@kolkov/angular-editor": [ 28 | "dist/angular-editor" 29 | ], 30 | "@kolkov/angular-editor/*": [ 31 | "dist/angular-editor/*" 32 | ] 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-select/ae-select.component.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | {{item.label}} 13 | 14 | No items for select 15 | 16 | 17 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/angular-editor.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {AngularEditorComponent} from './editor/angular-editor.component'; 3 | import {AeToolbarComponent} from './ae-toolbar/ae-toolbar.component'; 4 | import {FormsModule, ReactiveFormsModule} from '@angular/forms'; 5 | import {CommonModule} from '@angular/common'; 6 | import { AeSelectComponent } from './ae-select/ae-select.component'; 7 | import {AeButtonComponent} from "./ae-button/ae-button.component"; 8 | import { AeToolbarSetComponent } from './ae-toolbar-set/ae-toolbar-set.component'; 9 | 10 | @NgModule({ 11 | imports: [ 12 | CommonModule, FormsModule, ReactiveFormsModule 13 | ], 14 | declarations: [AngularEditorComponent, AeToolbarComponent, AeSelectComponent, AeButtonComponent, AeToolbarSetComponent], 15 | exports: [AngularEditorComponent, AeToolbarComponent, AeButtonComponent, AeToolbarSetComponent] 16 | }) 17 | export class AngularEditorModule { 18 | } 19 | -------------------------------------------------------------------------------- /projects/angular-editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kolkov/angular-editor", 3 | "version": "3.0.3", 4 | "description": "A simple native WYSIWYG editor for Angular 20+. Rich Text editor component for Angular.", 5 | "author": "Andrey Kolkov ", 6 | "repository": "https://github.com/kolkov/angular-editor", 7 | "license": "MIT", 8 | "private": false, 9 | "bugs": { 10 | "url": "https://github.com/kolkov/angular-editor/issues" 11 | }, 12 | "peerDependencies": { 13 | "@angular/common": "^20.0.0 || ^21.0.0", 14 | "@angular/core": "^20.0.0 || ^21.0.0", 15 | "@angular/forms": "^20.0.0 || ^21.0.0", 16 | "rxjs": "^7.8.0" 17 | }, 18 | "dependencies": { 19 | "tslib": "^2.3.0" 20 | }, 21 | "keywords": [ 22 | "angular", 23 | "editor", 24 | "native", 25 | "wysiwyg", 26 | "angular-editor", 27 | "angular-wysiwyg-editor", 28 | "wysiwyg-editor", 29 | "rich", 30 | "rich text editor" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /projects/angular-editor-app/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | 'browserName': 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function() {} 25 | }, 26 | onPrepare() { 27 | require('ts-node').register({ 28 | project: require('path').join(__dirname, './tsconfig.json') 29 | }); 30 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 31 | } 32 | }; -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS file for @kolkov/angular-editor 2 | # 3 | # This file defines individuals or teams that are responsible for code in this repository. 4 | # Code owners are automatically requested for review when someone opens a pull request 5 | # that modifies code that they own. 6 | # 7 | # For more info: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 8 | 9 | # Default owners for everything in the repo 10 | # These owners will be requested for review unless a later match takes precedence 11 | * @kolkov 12 | 13 | # GitHub Actions workflows 14 | /.github/workflows/ @kolkov 15 | 16 | # Library source code 17 | /projects/angular-editor/src/ @kolkov 18 | 19 | # Documentation 20 | *.md @kolkov 21 | /docs/ @kolkov 22 | 23 | # Build and configuration files 24 | package.json @kolkov 25 | package-lock.json @kolkov 26 | angular.json @kolkov 27 | tsconfig*.json @kolkov 28 | 29 | # CI/CD and release management 30 | /.github/workflows/npm-publish.yml @kolkov 31 | CHANGELOG.md @kolkov 32 | -------------------------------------------------------------------------------- /projects/angular-editor/themes/default.scss: -------------------------------------------------------------------------------- 1 | // Fix for toolbarHiddenButtons option 2 | // The [hidden] attribute needs explicit styling to work without Bootstrap 3 | [hidden] { 4 | display: none !important; 5 | } 6 | 7 | :root { 8 | --ae-gap: 5px; 9 | --ae-text-area-border: 1px solid #ddd; 10 | --ae-text-area-border-radius: 0; 11 | --ae-focus-outline-color: #afaeae auto 1px; 12 | --ae-toolbar-padding: 1px; 13 | --ae-toolbar-bg-color: #f5f5f5; 14 | --ae-toolbar-border-radius: 1px solid #ddd; 15 | --ae-button-bg-color: white; 16 | --ae-button-border: 1px solid #afaeae; 17 | --ae-button-radius: 5px; 18 | --ae-button-hover-bg-color: #f1f1f1; 19 | --ae-button-active-bg-color: #fffbd3; 20 | --ae-button-active-hover-bg-color: #fffaad; 21 | --ae-button-disabled-bg-color: #f5f5f5; 22 | --ae-picker-label-color: white; 23 | --ae-picker-icon-bg-color: white; 24 | --ae-picker-option-bg-color: #fff; 25 | --ae-picker-option-active-bg-color: #fffbd3; 26 | --ae-picker-option-focused-bg-color: #fffaad; 27 | --ae-picker-option-hover-bg-color: #fbf7ba; 28 | } 29 | -------------------------------------------------------------------------------- /projects/angular-editor/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": [ 4 | "!**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "projects/angular-editor/tsconfig.lib.json", 14 | "projects/angular-editor/tsconfig.spec.json" 15 | ], 16 | "createDefaultProgram": true 17 | }, 18 | "rules": { 19 | "@angular-eslint/directive-selector": [ 20 | "error", 21 | { 22 | "type": "attribute", 23 | "style": "camelCase" 24 | } 25 | ], 26 | "@angular-eslint/component-selector": [ 27 | "error", 28 | { 29 | "type": "element", 30 | "style": "kebab-case" 31 | } 32 | ], 33 | "@angular-eslint/prefer-standalone": "off", 34 | "@angular-eslint/prefer-inject": "off" 35 | } 36 | }, 37 | { 38 | "files": [ 39 | "*.html" 40 | ], 41 | "rules": { 42 | } 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Andrey Kolkov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(async(() => { 6 | TestBed.configureTestingModule({ 7 | declarations: [ 8 | AppComponent 9 | ], 10 | }).compileComponents(); 11 | })); 12 | 13 | it('should create the app', () => { 14 | const fixture = TestBed.createComponent(AppComponent); 15 | const app = fixture.debugElement.componentInstance; 16 | expect(app).toBeTruthy(); 17 | }); 18 | 19 | it(`should have as title 'angular-editor-app'`, () => { 20 | const fixture = TestBed.createComponent(AppComponent); 21 | const app = fixture.debugElement.componentInstance; 22 | expect(app.title).toEqual('angular-editor-app'); 23 | }); 24 | 25 | it('should render title in a h1 tag', () => { 26 | const fixture = TestBed.createComponent(AppComponent); 27 | fixture.detectChanges(); 28 | const compiled = fixture.debugElement.nativeElement; 29 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to angular-editor-app!'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /projects/angular-editor-app/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../../coverage/angular-editor-app'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /projects/angular-editor-app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": [ 4 | "!**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "projects/angular-editor-app/tsconfig.app.json", 14 | "projects/angular-editor-app/tsconfig.spec.json", 15 | "projects/angular-editor-app/e2e/tsconfig.json" 16 | ], 17 | "createDefaultProgram": true 18 | }, 19 | "rules": { 20 | "@angular-eslint/directive-selector": [ 21 | "error", 22 | { 23 | "type": "attribute", 24 | "prefix": "app", 25 | "style": "camelCase" 26 | } 27 | ], 28 | "@angular-eslint/component-selector": [ 29 | "error", 30 | { 31 | "type": "element", 32 | "prefix": "app", 33 | "style": "kebab-case" 34 | } 35 | ], 36 | "@angular-eslint/no-host-metadata-property": "warn" 37 | } 38 | }, 39 | { 40 | "files": [ 41 | "*.html" 42 | ], 43 | "rules": { 44 | } 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "tsconfig.json", 14 | "e2e/tsconfig.json" 15 | ], 16 | "createDefaultProgram": true 17 | }, 18 | "extends": [ 19 | "plugin:@angular-eslint/recommended", 20 | "plugin:@angular-eslint/template/process-inline-templates" 21 | ], 22 | "rules": { 23 | "@angular-eslint/component-selector": [ 24 | "error", 25 | { 26 | "prefix": "lib", 27 | "style": "kebab-case", 28 | "type": "element" 29 | } 30 | ], 31 | "@angular-eslint/directive-selector": [ 32 | "error", 33 | { 34 | "prefix": "lib", 35 | "style": "camelCase", 36 | "type": "attribute" 37 | } 38 | ], 39 | "@angular-eslint/prefer-standalone": "off", 40 | "@angular-eslint/prefer-inject": "off" 41 | } 42 | }, 43 | { 44 | "files": [ 45 | "*.html" 46 | ], 47 | "extends": [ 48 | "plugin:@angular-eslint/template/recommended" 49 | ], 50 | "rules": { 51 | } 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-button/ae-button.component.scss: -------------------------------------------------------------------------------- 1 | @import "../styles"; 2 | 3 | :host { 4 | &.angular-editor-button { 5 | background-color: var(--ae-button-bg-color, white); 6 | vertical-align: middle; 7 | border: var(--ae-button-border, 1px solid #ddd); 8 | border-radius: var(--ae-button-radius, 4px); 9 | padding: 0.4rem; 10 | float: left; 11 | width: 2rem; 12 | height: 2rem; 13 | 14 | svg { 15 | width: 100%; 16 | height: 100%; 17 | } 18 | 19 | &:hover { 20 | cursor: pointer; 21 | background-color: var(--ae-button-hover-bg-color, #f1f1f1); 22 | transition: 0.2s ease; 23 | } 24 | 25 | &:focus, 26 | &.focus { 27 | outline: 0; 28 | } 29 | 30 | &:disabled { 31 | background-color: var(--ae-button-disabled-bg-color,#f5f5f5); 32 | pointer-events: none; 33 | cursor: not-allowed; 34 | 35 | > .color-label { 36 | pointer-events: none; 37 | cursor: not-allowed; 38 | 39 | &.foreground { 40 | :after { 41 | background: #555555; 42 | } 43 | } 44 | 45 | &.background { 46 | background: #555555; 47 | } 48 | } 49 | } 50 | 51 | &.active { 52 | background: var(--ae-button-active-bg-color, #fffbd3); 53 | 54 | &:hover { 55 | background-color: var(--ae-button-active-hover-bg-color, #fffaad); 56 | } 57 | } 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /projects/angular-editor/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | type: "lcov", 29 | dir: require('path').join(__dirname, '../../coverage/angular-editor'), 30 | subdir: '.', 31 | }, 32 | reporters: ['progress', 'kjhtml'], 33 | port: 9876, 34 | colors: true, 35 | logLevel: config.LOG_INFO, 36 | autoWatch: true, 37 | browsers: ['Chrome'], 38 | customLaunchers: { 39 | ChromeHeadlessNoSandbox: { 40 | base: 'ChromeHeadless', 41 | flags: ['--no-sandbox'] 42 | } 43 | }, 44 | singleRun: false, 45 | restartOnFileChange: true 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/editor/angular-editor.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; 2 | 3 | import {AngularEditorComponent} from './angular-editor.component'; 4 | import {AeToolbarComponent} from '../ae-toolbar/ae-toolbar.component'; 5 | import {FormsModule} from '@angular/forms'; 6 | import {HttpClientModule} from '@angular/common/http'; 7 | import {AeSelectComponent} from '../ae-select/ae-select.component'; 8 | import {AngularEditorModule} from '../angular-editor.module'; 9 | 10 | describe('AngularEditorComponent', () => { 11 | let component: AngularEditorComponent; 12 | let fixture: ComponentFixture; 13 | 14 | beforeEach(waitForAsync(() => { 15 | TestBed.configureTestingModule({ 16 | imports: [ FormsModule, HttpClientModule], 17 | declarations: [AngularEditorComponent, AeToolbarComponent, AeSelectComponent] 18 | }) 19 | .compileComponents(); 20 | })); 21 | 22 | beforeEach(() => { 23 | fixture = TestBed.createComponent(AngularEditorComponent); 24 | component = fixture.componentInstance; 25 | fixture.detectChanges(); 26 | }); 27 | 28 | it('should create', () => { 29 | expect(component).toBeTruthy(); 30 | }); 31 | 32 | it('should paste raw text', () => { 33 | const htmlText = '

Hello!

'; 34 | const rawText = 'Hello!'; 35 | component.config.rawPaste = true; 36 | 37 | const dataTransfer = new DataTransfer(); 38 | 39 | const clipboardEvent = new ClipboardEvent("paste", { 40 | clipboardData: dataTransfer, 41 | }); 42 | clipboardEvent.clipboardData.setData("text/plain", rawText); 43 | clipboardEvent.clipboardData.setData("text/html", htmlText); 44 | 45 | const outputRawText = component.onPaste(clipboardEvent); 46 | 47 | expect(outputRawText).toEqual(rawText); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/editor/angular-editor.component.html: -------------------------------------------------------------------------------- 1 |
10 | 23 | 27 | 28 | 29 | 30 |
34 |
53 |
54 | {{ placeholder || config['placeholder'] }} 55 |
56 |
57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # compiled output 64 | /dist 65 | /tmp 66 | /out-tsc 67 | 68 | # dependencies 69 | /node_modules 70 | 71 | # IDEs and editors 72 | /.idea 73 | .project 74 | .classpath 75 | .c9/ 76 | *.launch 77 | .settings/ 78 | *.sublime-workspace 79 | 80 | # IDE - VSCode 81 | .vscode/* 82 | !.vscode/settings.json 83 | !.vscode/tasks.json 84 | !.vscode/launch.json 85 | !.vscode/extensions.json 86 | 87 | # Claude Code private configuration 88 | .claude/ 89 | .goco/ 90 | .goda/ 91 | 92 | # Private development documentation 93 | docs/dev/ 94 | INDEX.md 95 | 96 | # misc 97 | /.angular/cache 98 | /.sass-cache 99 | /connect.lock 100 | /coverage 101 | /libpeerconnection.log 102 | npm-debug.log 103 | yarn-error.log 104 | testem.log 105 | /typings 106 | 107 | # System Files 108 | .DS_Store 109 | Thumbs.db 110 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # Trigger on version tags like v3.0.0, v3.1.0, etc. 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write # Required for creating GitHub releases 13 | id-token: write # Required for npm trusted publishing (OIDC) 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' # Use Node 20 LTS 23 | registry-url: 'https://registry.npmjs.org' 24 | cache: 'npm' 25 | always-auth: true 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Run tests 31 | run: npm run test:lib -- --watch=false --browsers=ChromeHeadless 32 | 33 | - name: Build production library 34 | run: npm run build-prod:lib 35 | 36 | - name: Copy files to dist 37 | run: | 38 | npm run copy:readme 39 | npm run copy:changelog 40 | npm run copy:license 41 | 42 | - name: Determine npm tag 43 | id: npm-tag 44 | run: | 45 | VERSION=$(node -p "require('./package.json').version") 46 | if [[ "$VERSION" == *"beta"* ]] || [[ "$VERSION" == *"alpha"* ]] || [[ "$VERSION" == *"rc"* ]]; then 47 | echo "tag=next" >> $GITHUB_OUTPUT 48 | else 49 | echo "tag=latest" >> $GITHUB_OUTPUT 50 | fi 51 | 52 | - name: Publish to npm (Trusted Publishing) 53 | run: npm publish ./dist/angular-editor --tag ${{ steps.npm-tag.outputs.tag }} --provenance --access public 54 | env: 55 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 56 | 57 | - name: Create GitHub Release 58 | uses: softprops/action-gh-release@v2 59 | with: 60 | body_path: CHANGELOG.md 61 | generate_release_notes: true 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/editor/angular-editor.component.scss: -------------------------------------------------------------------------------- 1 | @import "../styles"; 2 | 3 | .angular-editor { 4 | display: flex; 5 | flex-direction: column; 6 | gap: var(--ae-gap, 5px); 7 | 8 | &.bottom { 9 | flex-direction: column-reverse; 10 | } 11 | 12 | ::ng-deep [contenteditable=true]:empty:before { 13 | content: attr(placeholder); 14 | color: #868e96; 15 | opacity: 1; 16 | } 17 | 18 | .angular-editor-wrapper { 19 | position: relative; 20 | 21 | .angular-editor-textarea { 22 | min-height: 150px; 23 | overflow: auto; 24 | resize: vertical; 25 | 26 | &:after { 27 | content: ""; 28 | position: absolute; 29 | bottom: 0; 30 | right: 0; 31 | display: block; 32 | width: 8px; 33 | height: 8px; 34 | cursor: nwse-resize; 35 | background-color: rgba(255, 255, 255, 0.5) 36 | } 37 | } 38 | 39 | .angular-editor-textarea { 40 | min-height: 5rem; 41 | padding: 0.5rem 0.8rem 1rem 0.8rem; 42 | border-radius: var(--ae-text-area-border-radius, 0.3rem); 43 | border: var(--ae-text-area-border, 1px solid #ddd); 44 | background-color: transparent; 45 | overflow-x: hidden; 46 | overflow-y: auto; 47 | position: relative; 48 | 49 | &:focus, 50 | &.focus { 51 | outline: var(--ae-focus-outline-color, -webkit-focus-ring-color auto 1px); 52 | } 53 | 54 | ::ng-deep blockquote { 55 | margin-left: 1rem; 56 | border-left: 0.2em solid #dfe2e5; 57 | padding-left: 0.5rem; 58 | } 59 | } 60 | 61 | ::ng-deep p { 62 | margin-bottom: 0; 63 | } 64 | 65 | .angular-editor-placeholder { 66 | display: none; 67 | position: absolute; 68 | top: 0; 69 | padding: 0.6rem 0.8rem 1rem 0.9rem; 70 | color: #6c757d; 71 | opacity: 0.75; 72 | } 73 | 74 | &.show-placeholder { 75 | .angular-editor-placeholder { 76 | display: block; 77 | } 78 | } 79 | 80 | &.disabled { 81 | cursor: not-allowed; 82 | opacity: 0.5; 83 | pointer-events: none; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags.ts'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js'; // Included with Angular CLI. 49 | 50 | 51 | /*************************************************************************************************** 52 | * APPLICATION IMPORTS 53 | */ 54 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { UploadResponse } from './angular-editor.service'; 2 | import { HttpEvent } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | 5 | export interface CustomClass { 6 | name: string; 7 | class: string; 8 | tag?: string; 9 | } 10 | 11 | export interface Font { 12 | name: string; 13 | class: string; 14 | } 15 | 16 | export interface AngularEditorConfig { 17 | editable?: boolean; 18 | spellcheck?: boolean; 19 | height?: 'auto' | string; 20 | minHeight?: '0' | string; 21 | maxHeight?: 'auto' | string; 22 | width?: 'auto' | string; 23 | minWidth?: '0' | string; 24 | translate?: 'yes' | 'now' | string; 25 | enableToolbar?: boolean; 26 | showToolbar?: boolean; 27 | placeholder?: string; 28 | defaultParagraphSeparator?: string; 29 | defaultFontName?: string; 30 | defaultFontSize?: '1' | '2' | '3' | '4' | '5' | '6' | '7' | string; 31 | uploadUrl?: string; 32 | upload?: (file: File) => Observable>; 33 | uploadWithCredentials?: boolean; 34 | fonts?: Font[]; 35 | customClasses?: CustomClass[]; 36 | sanitize?: boolean; 37 | toolbarPosition?: 'top' | 'bottom'; 38 | outline?: boolean; 39 | toolbarHiddenButtons?: string[][]; 40 | rawPaste?: boolean; 41 | } 42 | 43 | export const angularEditorConfig: AngularEditorConfig = { 44 | editable: true, 45 | spellcheck: true, 46 | height: 'auto', 47 | minHeight: '0', 48 | maxHeight: 'auto', 49 | width: 'auto', 50 | minWidth: '0', 51 | translate: 'yes', 52 | enableToolbar: true, 53 | showToolbar: true, 54 | placeholder: 'Enter text here...', 55 | defaultParagraphSeparator: '', 56 | defaultFontName: '', 57 | defaultFontSize: '', 58 | fonts: [ 59 | {class: 'arial', name: 'Arial'}, 60 | {class: 'times-new-roman', name: 'Times New Roman'}, 61 | {class: 'calibri', name: 'Calibri'}, 62 | {class: 'comic-sans-ms', name: 'Comic Sans MS'} 63 | ], 64 | uploadUrl: 'v1/image', 65 | uploadWithCredentials: false, 66 | sanitize: true, 67 | toolbarPosition: 'top', 68 | outline: true, 69 | /*toolbarHiddenButtons: [ 70 | ['bold', 'italic', 'underline', 'strikeThrough', 'superscript', 'subscript'], 71 | ['heading', 'fontName', 'fontSize', 'color'], 72 | ['justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull', 'indent', 'outdent'], 73 | ['cut', 'copy', 'delete', 'removeFormat', 'undo', 'redo'], 74 | ['paragraph', 'blockquote', 'removeBlockquote', 'horizontalLine', 'orderedList', 'unorderedList'], 75 | ['link', 'unlink', 'image', 'video'] 76 | ]*/ 77 | }; 78 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Angular Editor

3 |

Get it here

4 | 5 | 6 | 7 |

8 | 10 | 11 | 12 | 32 | 33 | 34 | 35 |

36 | HTML Output: {{ htmlContent1 }} 37 |

38 |
39 | 41 |
42 |

43 | Form Value: {{ form.value.signature }} 44 |

45 |

46 | Form Status: {{ form.status }} 47 |

48 |
49 | 50 | -------------------------------------------------------------------------------- /projects/angular-editor-app/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | import { AngularEditorConfig } from '@kolkov/angular-editor'; 4 | 5 | const ANGULAR_EDITOR_LOGO_URL = 'https://raw.githubusercontent.com/kolkov/angular-editor/master/docs/angular-editor-logo.png?raw=true' 6 | 7 | @Component({ 8 | selector: 'app-root', 9 | templateUrl: './app.component.html', 10 | styleUrls: ['./app.component.scss'], 11 | standalone: false 12 | }) 13 | export class AppComponent implements OnInit { 14 | title = 'app'; 15 | 16 | form: FormGroup; 17 | 18 | htmlContent1 = ''; 19 | htmlContent2 = ''; 20 | angularEditorLogo = `angular editor logo`; 21 | 22 | config1: AngularEditorConfig = { 23 | editable: true, 24 | spellcheck: true, 25 | minHeight: '5rem', 26 | maxHeight: '15rem', 27 | placeholder: 'Enter text here...', 28 | translate: 'no', 29 | sanitize: false, 30 | // toolbarPosition: 'top', 31 | outline: true, 32 | defaultFontName: 'Comic Sans MS', 33 | defaultFontSize: '5', 34 | // showToolbar: false, 35 | defaultParagraphSeparator: 'p', 36 | customClasses: [ 37 | { 38 | name: 'quote', 39 | class: 'quote', 40 | }, 41 | { 42 | name: 'redText', 43 | class: 'redText' 44 | }, 45 | { 46 | name: 'titleText', 47 | class: 'titleText', 48 | tag: 'h1', 49 | }, 50 | ], 51 | toolbarHiddenButtons: [ 52 | ['bold', 'italic'], 53 | ] 54 | }; 55 | 56 | config2: AngularEditorConfig = { 57 | editable: true, 58 | spellcheck: true, 59 | minHeight: '5rem', 60 | maxHeight: '15rem', 61 | placeholder: 'Enter text here...', 62 | translate: 'no', 63 | sanitize: true, 64 | toolbarPosition: 'bottom', 65 | defaultFontName: 'Comic Sans MS', 66 | defaultFontSize: '5', 67 | defaultParagraphSeparator: 'p', 68 | customClasses: [ 69 | { 70 | name: 'quote', 71 | class: 'quote', 72 | }, 73 | { 74 | name: 'redText', 75 | class: 'redText' 76 | }, 77 | { 78 | name: 'titleText', 79 | class: 'titleText', 80 | tag: 'h1', 81 | }, 82 | ] 83 | }; 84 | 85 | constructor(private formBuilder: FormBuilder) {} 86 | 87 | ngOnInit() { 88 | this.form = this.formBuilder.group({ 89 | signature: ['', Validators.required] 90 | }); 91 | console.log(this.htmlContent1); 92 | } 93 | 94 | onChange(event) { 95 | console.log('changed'); 96 | } 97 | 98 | onBlur(event) { 99 | console.log('blur ' + event); 100 | } 101 | 102 | onChange2(event) { 103 | console.warn(this.form.value); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kolkov/angular-editor", 3 | "version": "3.0.1", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build angular-editor-app", 8 | "build-prod": "ng build angular-editor-app --configuration production", 9 | "test": "ng test", 10 | "lint:lib": "ng lint angular-editor", 11 | "e2e": "ng e2e", 12 | "build-watch:lib": "ng build angular-editor --watch", 13 | "test:lib": "ng test angular-editor", 14 | "build:lib": "ng-packagr -p projects/angular-editor/ng-package.json", 15 | "build-prod:lib": "ng-packagr -p projects/angular-editor/ng-package.json -c projects/angular-editor/tsconfig.lib.prod.json", 16 | "publish:lib": "npm run copy:readme && npm run copy:changelog && npm run copy:license && npm publish ./dist/angular-editor --tag latest", 17 | "publish:lib:next": "npm run copy:readme && npm run copy:changelog && npm run copy:license && npm publish ./dist/angular-editor --tag next", 18 | "copy:readme": "cpx README.md dist/angular-editor", 19 | "copy:changelog": "cpx CHANGELOG.md dist/angular-editor", 20 | "copy:license": "cpx LICENSE dist/angular-editor", 21 | "test-ci": "ng test angular-editor --code-coverage --no-watch --browsers=ChromeHeadless && cat ./coverage/angular-editor/lcov.info | coveralls" 22 | }, 23 | "private": true, 24 | "dependencies": { 25 | "@angular/animations": "^20.3.13", 26 | "@angular/common": "^20.3.13", 27 | "@angular/compiler": "^20.3.13", 28 | "@angular/core": "^20.3.13", 29 | "@angular/forms": "^20.3.13", 30 | "@angular/platform-browser": "^20.3.13", 31 | "@angular/platform-browser-dynamic": "^20.3.13", 32 | "@angular/router": "^20.3.13", 33 | "rxjs": "^7.8.2", 34 | "tslib": "^2.4.0", 35 | "zone.js": "~0.15.1" 36 | }, 37 | "devDependencies": { 38 | "@angular-devkit/build-angular": "^20.3.11", 39 | "@angular-eslint/builder": "^20.6.0", 40 | "@angular-eslint/eslint-plugin": "^20.6.0", 41 | "@angular-eslint/eslint-plugin-template": "^20.6.0", 42 | "@angular-eslint/schematics": "^20.6.0", 43 | "@angular-eslint/template-parser": "^20.6.0", 44 | "@angular/cli": "^20.3.11", 45 | "@angular/compiler-cli": "^20.3.13", 46 | "@angular/language-service": "^20.3.13", 47 | "@types/jasmine": "~5.1.0", 48 | "@types/jasminewd2": "^2.0.10", 49 | "@types/node": "^18.19.0", 50 | "@typescript-eslint/eslint-plugin": "^7.18.0", 51 | "@typescript-eslint/parser": "^7.18.0", 52 | "coveralls": "^3.1.1", 53 | "cpx2": "^4.2.0", 54 | "eslint": "^8.57.0", 55 | "jasmine-core": "~5.1.0", 56 | "jasmine-spec-reporter": "~7.0.0", 57 | "karma": "^6.4.0", 58 | "karma-chrome-launcher": "^3.2.0", 59 | "karma-coverage": "^2.2.0", 60 | "karma-jasmine": "~5.1.0", 61 | "karma-jasmine-html-reporter": "^2.1.0", 62 | "ng-packagr": "^20.3.2", 63 | "protractor": "~7.0.0", 64 | "ts-node": "~10.9.0", 65 | "typescript": "~5.8.3", 66 | "webpack": "^5.95.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-select/ae-select.component.scss: -------------------------------------------------------------------------------- 1 | @import "../styles"; 2 | 3 | svg { 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | .ae-picker { 9 | color: var(--ae-picker-color, #444); 10 | display: inline-block; 11 | float: left; 12 | width: 100%; 13 | position: relative; 14 | vertical-align: middle; 15 | } 16 | 17 | .ae-picker-label { 18 | cursor: pointer; 19 | display: inline-block; 20 | padding-left: 8px; 21 | padding-right: 10px; 22 | position: relative; 23 | width: 100%; 24 | line-height: 1.8rem; 25 | vertical-align: middle; 26 | font-size: 85%; 27 | text-align: left; 28 | background-color: var(--ae-picker-label-color, white); 29 | min-width: 2rem; 30 | float: left; 31 | border: 1px solid #ddd; 32 | border-radius: var(--ae-button-radius, 4px); 33 | text-overflow: clip; 34 | overflow: hidden; 35 | white-space: nowrap; 36 | height: 2rem; 37 | 38 | &:before { 39 | content: ''; 40 | position: absolute; 41 | right: 0; 42 | top: 0; 43 | width: 20px; 44 | height: 100%; 45 | background: linear-gradient(to right, var(--ae-picker-label-color, white), var(--ae-picker-label-color, white) 100%); 46 | } 47 | 48 | &:focus { 49 | outline: none; 50 | } 51 | 52 | &:hover { 53 | cursor: pointer; 54 | background-color: #f1f1f1; 55 | transition: 0.2s ease; 56 | 57 | &:before { 58 | background: linear-gradient(to right, #f5f5f5 100%, #ffffff 100%); 59 | } 60 | } 61 | 62 | &:disabled { 63 | background-color: #f5f5f5; 64 | pointer-events: none; 65 | cursor: not-allowed; 66 | 67 | &:before { 68 | background: linear-gradient(to right, #f5f5f5 100%, #ffffff 100%); 69 | } 70 | } 71 | 72 | svg { 73 | position: absolute; 74 | right: 0; 75 | width: 1rem; 76 | 77 | &:not(:root) { 78 | overflow: hidden; 79 | } 80 | 81 | .ae-stroke { 82 | fill: none; 83 | stroke: #444; 84 | stroke-linecap: round; 85 | stroke-linejoin: round; 86 | stroke-width: 2; 87 | } 88 | } 89 | } 90 | 91 | .ae-picker-options { 92 | background-color: var(--ae-picker-option-bg-color, #fff); 93 | display: none; 94 | min-width: 100%; 95 | position: absolute; 96 | white-space: nowrap; 97 | z-index: 3; 98 | border: 1px solid transparent; 99 | box-shadow: rgba(0, 0, 0, 0.2) 0 2px 8px; 100 | 101 | .ae-picker-item { 102 | cursor: pointer; 103 | display: block; 104 | padding: 5px; 105 | z-index: 3; 106 | text-align: left; 107 | background-color: transparent; 108 | min-width: 2rem; 109 | //width: 100%; 110 | border: 0 solid #ddd; 111 | 112 | &.selected { 113 | color: #06c; 114 | background-color: var(--ae-picker-option-active-bg-color, #fff4c2); 115 | } 116 | 117 | &.focused { 118 | background-color: var(--ae-picker-option-focused-bg-color, #fbf9b0); 119 | } 120 | 121 | &:hover { 122 | background-color: var(--ae-picker-option-hover-bg-color,#fffa98); 123 | } 124 | } 125 | } 126 | 127 | .ae-expanded { 128 | display: block; 129 | margin-top: -1px; 130 | z-index: 1; 131 | 132 | .ae-picker-label { 133 | color: #ccc; 134 | z-index: 2; 135 | 136 | svg { 137 | color: #ccc; 138 | z-index: 2; 139 | 140 | .ae-stroke { 141 | stroke: #ccc; 142 | } 143 | } 144 | } 145 | 146 | .ae-picker-options { 147 | display: flex; 148 | flex-direction: column; 149 | margin-top: -1px; 150 | top: 100%; 151 | z-index: 3; 152 | border-color: #ccc; 153 | } 154 | } 155 | 156 | 157 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-select/ae-select.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { AeSelectComponent, SelectOption } from './ae-select.component'; 4 | import { By } from '@angular/platform-browser'; 5 | 6 | describe('AeSelectComponent', () => { 7 | let component: AeSelectComponent; 8 | let fixture: ComponentFixture; 9 | 10 | const testOptions: SelectOption[] = [ 11 | { 12 | label: 'test label1', 13 | value: 'test value1' 14 | }, 15 | { 16 | label: 'test label2', 17 | value: 'test value2' 18 | } 19 | ]; 20 | 21 | beforeEach(waitForAsync(() => { 22 | TestBed.configureTestingModule({ 23 | declarations: [ AeSelectComponent ] 24 | }) 25 | .compileComponents(); 26 | })); 27 | 28 | beforeEach(() => { 29 | fixture = TestBed.createComponent(AeSelectComponent); 30 | component = fixture.componentInstance; 31 | fixture.detectChanges(); 32 | }); 33 | 34 | it('should create', () => { 35 | expect(component).toBeTruthy(); 36 | }); 37 | 38 | it('should be visible after initialized', () => { 39 | const hide = spyOn(component, 'hide'); 40 | component.ngOnInit(); 41 | expect(component.hidden).toBe('inline-block'); 42 | expect(hide).not.toHaveBeenCalled(); 43 | }); 44 | 45 | it('should select first option after initialized', () => { 46 | component.options = testOptions; 47 | component.ngOnInit(); 48 | expect(component.selectedOption).toBe(testOptions[0]); 49 | }); 50 | 51 | it('should call hide method after initialized when passed isHidden: true', () => { 52 | const hide = spyOn(component, 'hide'); 53 | component.isHidden = true; 54 | component.ngOnInit(); 55 | expect(hide).toHaveBeenCalled(); 56 | }); 57 | 58 | it('should be hidden after called hide method', () => { 59 | component.hide(); 60 | expect(component.hidden).toBe('none'); 61 | }); 62 | 63 | it('should render options', () => { 64 | component.options = testOptions; 65 | component.selectedOption = testOptions[0]; 66 | fixture.detectChanges(); 67 | 68 | const options = fixture.debugElement.queryAll(By.css('.ae-picker-item')); 69 | expect(options.length).toBe(2); 70 | }); 71 | 72 | it('should select option by mousedown', () => { 73 | component.options = testOptions; 74 | component.selectedOption = testOptions[0]; 75 | fixture.detectChanges(); 76 | 77 | const options = fixture.debugElement.queryAll(By.css('.ae-picker-item')); 78 | const optionSelect = spyOn(component, 'optionSelect'); 79 | options[1].triggerEventHandler('mousedown', {}); 80 | expect(optionSelect).toHaveBeenCalledWith(testOptions[1], {} as MouseEvent); 81 | }); 82 | 83 | it('should select option and close after', () => { 84 | const event = new MouseEvent('mousedown', { buttons: 1 }); 85 | const stopPropagation = spyOn(event, 'stopPropagation'); 86 | const setValue = spyOn(component, 'setValue').and.callFake(() => {}); 87 | const onChange = spyOn(component, 'onChange').and.callFake(() => {}); 88 | const onTouched = spyOn(component, 'onTouched'); 89 | const changeEvent = spyOn(component.changeEvent, 'emit').and.callFake(() => {}); 90 | 91 | component.opened = true; 92 | 93 | component.selectedOption = testOptions[1]; 94 | 95 | component.optionSelect(testOptions[1], event); 96 | 97 | expect(stopPropagation).toHaveBeenCalled(); 98 | expect(setValue).toHaveBeenCalledWith(testOptions[1].value); 99 | expect(onChange).toHaveBeenCalledWith(testOptions[1].value); 100 | expect(onTouched).toHaveBeenCalled(); 101 | expect(changeEvent).toHaveBeenCalledWith(testOptions[1].value); 102 | expect(component.opened).toBe(false); 103 | }); 104 | 105 | }); 106 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Report an Issue 3 | 4 | Help us make AngularEditor better! If you think you might have found a bug, or some other weirdness, start by making sure 5 | it hasn't already been reported. You can [search through existing @kolkov/angular-editor issues](https://github.com/kolkov/angular-editor/issues) 6 | to see if someone's reported one similar to yours. 7 | 8 | If not, then [create a plunkr](http://bit.ly/UIR-Plunk) that demonstrates the problem (try to use as little code 9 | as possible: the more minimalist, the faster we can debug it). 10 | 11 | Next, [create a new issue](https://github.com/kolkov/angular-editor/issues/new) that briefly explains the problem, 12 | and provides a bit of background as to the circumstances that triggered it. Don't forget to include the link to 13 | that plunkr you created! 14 | 15 | **Note**: If you're unsure how a feature is used, or are encountering some unexpected behavior that you aren't sure 16 | is a bug, it's best to talk it out on 17 | [StackOverflow](http://stackoverflow.com/questions/ask?tags=angular,@kolkov/angular-editor) before reporting it. This 18 | keeps development streamlined, and helps us focus on building great software. 19 | 20 | 21 | Issues only! | 22 | -------------| 23 | Please keep in mind that the issue tracker is for *issues*. Please do *not* post an issue if you need help or support. Instead, use StackOverflow. | 24 | 25 | # Contribute 26 | 27 | **(1)** See the **[Developing](#developing)** section below, to get the development version of AngularEditor up and running on your local machine. 28 | 29 | **(2)** Check out the [roadmap](https://github.com/kolkov/angular-editor/milestones) to see where the project is headed, and if your feature idea fits with where we're headed. 30 | 31 | **(3)** If you're not sure, [open an RFC](https://github.com/kolkov/angular-editor/issues/new?title=RFC:%20My%20idea) to get some feedback on your idea. 32 | 33 | **(4)** Finally, commit some code and open a pull request. Code & commits should abide by the following rules: 34 | 35 | - *Always* have test coverage for new features (or regression tests for bug fixes), and *never* break existing tests 36 | - Commits should represent one logical change each; if a feature goes through multiple iterations, squash your commits down to one 37 | - Make sure to follow the [Angular commit message format](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#commit-message-format) so your change will appear in the changelog of the next release. 38 | - Changes should always respect the coding style of the project 39 | 40 | 41 | 42 | # Developing 43 | 44 | `angular-editor` uses Angular cli, npm and webpack. 45 | 46 | ## Fetch the source code 47 | 48 | The code for `angular-editor` is : 49 | 50 | * [AngularEditor](https://github.com/kolkov/angular-editor) (`@kolkov/angular-editor` on npm) 51 | 52 | Clone repository. 53 | 54 | ``` 55 | mkdir angular-editor 56 | cd angular-editor 57 | git clone https://github.com/kolkov/angular-editor.git 58 | ``` 59 | 60 | ## Install dependencies 61 | 62 | Use `npm` to install the development dependencies for the repository. 63 | 64 | ``` 65 | cd angular-editor 66 | npm install 67 | ``` 68 | 69 | After executing these steps, your local copy of `@kolkov/angular-editor-app` will be built using your local copy of `@kolkov/angular-editor` 70 | instead of the prebuilt version specified in `package.json`. 71 | 72 | ## Develop 73 | 74 | * `npm run build-watch:lib`: Continuously builds the `@kolkov/angular-editor` library when sources change. 75 | * `npm start`: Runs the demo app (requires library to be built first or running in watch mode) 76 | 77 | **Recommended development workflow:** 78 | ```bash 79 | # Terminal 1: Watch and rebuild library on changes 80 | npm run build-watch:lib 81 | 82 | # Terminal 2: Run demo app 83 | npm start 84 | ``` 85 | 86 | This setup ensures the demo app automatically picks up library changes. 87 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflows 2 | 3 | ## npm-publish.yml 4 | 5 | Автоматическая публикация библиотеки в npm при создании git tag. 6 | 7 | ### Как работает: 8 | 9 | 1. **Триггер**: Срабатывает при push тега вида `v*` (например, `v3.0.0`, `v3.1.0`) 10 | 2. **Сборка**: Запускает тесты и production build библиотеки 11 | 3. **Публикация**: Определяет npm tag автоматически: 12 | - Stable версии (3.0.0, 3.1.0) → публикуются с тегом `latest` 13 | - Pre-release (3.0.0-beta.1, 3.1.0-rc.1) → публикуются с тегом `next` 14 | 4. **GitHub Release**: Автоматически создаёт GitHub Release с CHANGELOG 15 | 16 | ### Настройка (один раз): 17 | 18 | #### 🔐 NPM Trusted Publishing (Рекомендуется) 19 | 20 | **Современный способ без токенов! (с 15 ноября 2025 классические токены будут отключены)** 21 | 22 | 1. Зайти на [npmjs.com](https://www.npmjs.com/) 23 | 2. Перейти к пакету: `@kolkov/angular-editor` 24 | 3. **Settings** → **Publishing Access** 25 | 4. **Add Trusted Publisher** 26 | 5. Заполнить форму: 27 | - **Provider**: GitHub 28 | - **Organization/User**: `kolkov` 29 | - **Repository**: `angular-editor` 30 | - **Workflow filename**: `npm-publish.yml` 31 | - **Environment**: оставить пустым (или указать `production` если используете) 32 | 33 | 6. **Save** → Готово! 🎉 34 | 35 | **Преимущества:** 36 | - ✅ Не нужны токены (NPM_TOKEN) 37 | - ✅ Автоматическая ротация ключей 38 | - ✅ Provenance (криптографическое подтверждение источника) 39 | - ✅ Повышенная безопасность через OIDC 40 | 41 | #### 🔑 Альтернатива: Granular Access Token (устаревший способ) 42 | 43 | **Используйте только если Trusted Publishing недоступен** 44 | 45 | 1. Зайти на [npmjs.com](https://www.npmjs.com/) 46 | 2. Settings → Access Tokens → Generate New Token 47 | 3. Выбрать тип: **Granular Access Token** 48 | 4. Настроить: 49 | - **Expiration**: максимум 90 дней 50 | - **Packages**: выбрать `@kolkov/angular-editor` 51 | - **Permissions**: Read and write 52 | 5. Скопировать токен 53 | 54 | 6. Открыть репозиторий на GitHub 55 | 7. Settings → Secrets and variables → Actions 56 | 8. New repository secret: 57 | - Name: `NPM_TOKEN` 58 | - Secret: вставить токен из npm 59 | 60 | 9. Обновить workflow: раскомментировать `NODE_AUTH_TOKEN` в шаге publish 61 | 62 | **Недостатки:** 63 | - ⚠️ Токен истекает через 7-90 дней 64 | - ⚠️ Нужно вручную обновлять в GitHub Secrets 65 | - ⚠️ Классические токены будут отключены 15.11.2025 66 | 67 | #### ✅ Готово! 68 | 69 | Теперь при каждом push тега библиотека будет автоматически опубликована. 70 | 71 | ### Как использовать: 72 | 73 | ```bash 74 | # 1. Обновить версию в package.json (уже сделано) 75 | 76 | # 2. Закоммитить изменения 77 | git add . 78 | git commit -m "chore(release): 3.0.0" 79 | 80 | # 3. Создать и запушить тег 81 | git tag v3.0.0 82 | git push origin v3.0.0 83 | 84 | # 4. GitHub Actions автоматически: 85 | # - Запустит тесты 86 | # - Соберёт production build 87 | # - Опубликует в npm 88 | # - Создаст GitHub Release 89 | ``` 90 | 91 | ### Особенности: 92 | 93 | - ✅ **Автоматическое определение тега**: beta/alpha/rc → `next`, stable → `latest` 94 | - ✅ **Provenance**: Публикация с npm provenance для безопасности 95 | - ✅ **Тесты**: Всегда запускаются перед публикацией 96 | - ✅ **GitHub Release**: Автоматически создаётся с CHANGELOG 97 | - ✅ **Безопасность**: NPM токен хранится в GitHub Secrets 98 | 99 | ### Проверка workflow: 100 | 101 | После push тега можно проверить статус: 102 | - GitHub → Actions tab 103 | - Там будет видно выполнение workflow 104 | 105 | ### Откат публикации: 106 | 107 | Если что-то пошло не так: 108 | 109 | ```bash 110 | # Удалить версию из npm (в течение 72 часов) 111 | npm unpublish @kolkov/angular-editor@3.0.0 112 | 113 | # Удалить тег с GitHub 114 | git push origin --delete v3.0.0 115 | 116 | # Удалить локальный тег 117 | git tag -d v3.0.0 118 | ``` 119 | 120 | **Важно**: npm unpublish работает только в течение 72 часов после публикации! 121 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-toolbar/ae-toolbar.component.scss: -------------------------------------------------------------------------------- 1 | @import "../styles"; 2 | 3 | .angular-editor-toolbar { 4 | font: 100 14px/15px Roboto, Arial, sans-serif; 5 | background-color: var(--ae-toolbar-bg-color, #f5f5f5); 6 | font-size: 0.8rem; 7 | padding: var(--ae-toolbar-padding,0.2rem); 8 | border: var(--ae-toolbar-border-radius, 1px solid #ddd); 9 | display: flex; 10 | flex-wrap: wrap; 11 | gap: 4px; 12 | } 13 | 14 | .select-heading { 15 | display: inline-block; 16 | width: 90px; 17 | @supports not( -moz-appearance:none ) { 18 | optgroup { 19 | font-size: 12px; 20 | background-color: #f4f4f4; 21 | padding: 5px; 22 | } 23 | option { 24 | border: 1px solid; 25 | background-color: white; 26 | } 27 | } 28 | 29 | &:disabled { 30 | background-color: #f5f5f5; 31 | pointer-events: none; 32 | cursor: not-allowed; 33 | } 34 | 35 | &:hover { 36 | cursor: pointer; 37 | background-color: #f1f1f1; 38 | transition: 0.2s ease; 39 | } 40 | } 41 | 42 | .select-font { 43 | display: inline-block; 44 | width: 90px; 45 | @supports not( -moz-appearance:none ) { 46 | optgroup { 47 | font-size: 12px; 48 | background-color: #f4f4f4; 49 | padding: 5px; 50 | } 51 | option { 52 | border: 1px solid; 53 | background-color: white; 54 | } 55 | } 56 | 57 | &:disabled { 58 | background-color: #f5f5f5; 59 | pointer-events: none; 60 | cursor: not-allowed; 61 | } 62 | 63 | &:hover { 64 | cursor: pointer; 65 | background-color: #f1f1f1; 66 | transition: 0.2s ease; 67 | } 68 | } 69 | 70 | .select-font-size { 71 | display: inline-block; 72 | width: 50px; 73 | @supports not( -moz-appearance:none ) { 74 | optgroup { 75 | font-size: 12px; 76 | background-color: #f4f4f4; 77 | padding: 5px; 78 | } 79 | option { 80 | border: 1px solid; 81 | background-color: white; 82 | } 83 | .size1 { 84 | font-size: 10px; 85 | } 86 | .size2 { 87 | font-size: 12px; 88 | } 89 | .size3 { 90 | font-size: 14px; 91 | } 92 | .size4 { 93 | font-size: 16px; 94 | } 95 | .size5 { 96 | font-size: 18px; 97 | } 98 | .size6 { 99 | font-size: 20px; 100 | } 101 | .size7 { 102 | font-size: 22px; 103 | } 104 | } 105 | 106 | &:disabled { 107 | background-color: #f5f5f5; 108 | pointer-events: none; 109 | cursor: not-allowed; 110 | } 111 | 112 | &:hover { 113 | cursor: pointer; 114 | background-color: #f1f1f1; 115 | transition: 0.2s ease; 116 | } 117 | } 118 | 119 | .select-custom-style { 120 | display: inline-block; 121 | width: 90px; 122 | @supports not( -moz-appearance:none ) { 123 | optgroup { 124 | font-size: 12px; 125 | background-color: #f4f4f4; 126 | padding: 5px; 127 | } 128 | option { 129 | border: 1px solid; 130 | background-color: white; 131 | } 132 | } 133 | 134 | &:disabled { 135 | background-color: #f5f5f5; 136 | pointer-events: none; 137 | cursor: not-allowed; 138 | } 139 | 140 | &:hover { 141 | cursor: pointer; 142 | background-color: #f1f1f1; 143 | transition: 0.2s ease; 144 | } 145 | } 146 | 147 | .color-label { 148 | position: relative; 149 | cursor: pointer; 150 | } 151 | 152 | .background { 153 | font-size: smaller; 154 | background: #1b1b1b; 155 | color: white; 156 | padding: 3px; 157 | } 158 | 159 | .foreground { 160 | :after { 161 | position: absolute; 162 | content: ""; 163 | left: -1px; 164 | top: auto; 165 | bottom: -3px; 166 | right: auto; 167 | width: 15px; 168 | height: 2px; 169 | z-index: 0; 170 | background: #1b1b1b; 171 | } 172 | } 173 | 174 | .default { 175 | font-size: 16px; 176 | } 177 | h1 { 178 | font-size: 24px; 179 | } 180 | h2 { 181 | font-size: 20px; 182 | } 183 | h3 { 184 | font-size: 16px; 185 | } 186 | h4 { 187 | font-size: 15px; 188 | } 189 | h5 { 190 | font-size: 14px; 191 | } 192 | h6 { 193 | font-size: 13px; 194 | } 195 | div { 196 | font-size: 12px; 197 | } 198 | pre { 199 | font-size: 12px; 200 | } 201 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-select/ae-select.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ElementRef, 4 | EventEmitter, 5 | forwardRef, 6 | HostBinding, 7 | HostListener, 8 | Input, 9 | OnInit, 10 | Output, 11 | Renderer2, 12 | ViewChild, 13 | ViewEncapsulation 14 | } from '@angular/core'; 15 | import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; 16 | import {isDefined} from '../utils'; 17 | 18 | export interface SelectOption { 19 | label: string; 20 | value: string; 21 | } 22 | 23 | @Component({ 24 | selector: 'ae-select', 25 | templateUrl: './ae-select.component.html', 26 | styleUrls: ['./ae-select.component.scss'], 27 | //encapsulation: ViewEncapsulation.None, 28 | providers: [ 29 | { 30 | provide: NG_VALUE_ACCESSOR, 31 | useExisting: forwardRef(() => AeSelectComponent), 32 | multi: true, 33 | } 34 | ], 35 | standalone: false 36 | }) 37 | export class AeSelectComponent implements OnInit, ControlValueAccessor { 38 | @Input() options: SelectOption[] = []; 39 | // eslint-disable-next-line @angular-eslint/no-input-rename 40 | @Input('hidden') isHidden: boolean; 41 | 42 | selectedOption: SelectOption; 43 | disabled = false; 44 | optionId = 0; 45 | 46 | get label(): string { 47 | return this.selectedOption && this.selectedOption.hasOwnProperty('label') ? this.selectedOption.label : 'Select'; 48 | } 49 | 50 | opened = false; 51 | 52 | get value(): string { 53 | return this.selectedOption.value; 54 | } 55 | 56 | @HostBinding('style.display') hidden = 'inline-block'; 57 | 58 | // eslint-disable-next-line @angular-eslint/no-output-native, @angular-eslint/no-output-rename 59 | @Output('change') changeEvent = new EventEmitter(); 60 | 61 | @ViewChild('labelButton', {static: true}) labelButton: ElementRef; 62 | 63 | constructor(private elRef: ElementRef, 64 | private r: Renderer2, 65 | ) { 66 | } 67 | 68 | ngOnInit() { 69 | this.selectedOption = this.options[0]; 70 | if (isDefined(this.isHidden) && this.isHidden) { 71 | this.hide(); 72 | } 73 | } 74 | 75 | hide() { 76 | this.hidden = 'none'; 77 | } 78 | 79 | optionSelect(option: SelectOption, event: MouseEvent) { 80 | //console.log(event.button, event.buttons); 81 | if (event.buttons !== 1) { 82 | return; 83 | } 84 | event.preventDefault(); 85 | event.stopPropagation(); 86 | this.setValue(option.value); 87 | this.onChange(this.selectedOption.value); 88 | this.changeEvent.emit(this.selectedOption.value); 89 | this.onTouched(); 90 | this.opened = false; 91 | } 92 | 93 | toggleOpen(event: MouseEvent) { 94 | // event.stopPropagation(); 95 | if (this.disabled) { 96 | return; 97 | } 98 | this.opened = !this.opened; 99 | } 100 | 101 | @HostListener('document:click', ['$event']) 102 | onClick($event: MouseEvent) { 103 | if (!this.elRef.nativeElement.contains($event.target)) { 104 | this.close(); 105 | } 106 | } 107 | 108 | close() { 109 | this.opened = false; 110 | } 111 | 112 | get isOpen(): boolean { 113 | return this.opened; 114 | } 115 | 116 | writeValue(value) { 117 | if (!value || typeof value !== 'string') { 118 | return; 119 | } 120 | this.setValue(value); 121 | } 122 | 123 | setValue(value) { 124 | let index = 0; 125 | const selectedEl = this.options.find((el, i) => { 126 | index = i; 127 | return el.value === value; 128 | }); 129 | if (selectedEl) { 130 | this.selectedOption = selectedEl; 131 | this.optionId = index; 132 | } 133 | } 134 | 135 | onChange: any = () => { 136 | } 137 | onTouched: any = () => { 138 | } 139 | 140 | registerOnChange(fn) { 141 | this.onChange = fn; 142 | } 143 | 144 | registerOnTouched(fn) { 145 | this.onTouched = fn; 146 | } 147 | 148 | setDisabledState(isDisabled: boolean): void { 149 | this.labelButton.nativeElement.disabled = isDisabled; 150 | const div = this.labelButton.nativeElement; 151 | const action = isDisabled ? 'addClass' : 'removeClass'; 152 | this.r[action](div, 'disabled'); 153 | this.disabled = isDisabled; 154 | } 155 | 156 | @HostListener('keydown', ['$event']) 157 | handleKeyDown($event: KeyboardEvent) { 158 | if (!this.opened) { 159 | return; 160 | } 161 | // console.log($event.key); 162 | // if (KeyCode[$event.key]) { 163 | switch ($event.key) { 164 | case 'ArrowDown': 165 | this._handleArrowDown($event); 166 | break; 167 | case 'ArrowUp': 168 | this._handleArrowUp($event); 169 | break; 170 | case 'Space': 171 | this._handleSpace($event); 172 | break; 173 | case 'Enter': 174 | this._handleEnter($event); 175 | break; 176 | case 'Tab': 177 | this._handleTab($event); 178 | break; 179 | case 'Escape': 180 | this.close(); 181 | $event.preventDefault(); 182 | break; 183 | case 'Backspace': 184 | this._handleBackspace(); 185 | break; 186 | } 187 | // } else if ($event.key && $event.key.length === 1) { 188 | // this._keyPress$.next($event.key.toLocaleLowerCase()); 189 | // } 190 | } 191 | 192 | _handleArrowDown($event) { 193 | if (this.optionId < this.options.length - 1) { 194 | this.optionId++; 195 | } 196 | } 197 | 198 | _handleArrowUp($event) { 199 | if (this.optionId >= 1) { 200 | this.optionId--; 201 | } 202 | } 203 | 204 | _handleSpace($event) { 205 | 206 | } 207 | 208 | _handleEnter($event) { 209 | this.optionSelect(this.options[this.optionId], $event); 210 | } 211 | 212 | _handleTab($event) { 213 | 214 | } 215 | 216 | _handleBackspace() { 217 | 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [3.0.3](https://github.com/kolkov/angular-editor/compare/v3.0.2...v3.0.3) (2025-01-22) - Security Hotfix 3 | 4 | ### Security 5 | * **CRITICAL:** Fixed XSS vulnerability in `refreshView()` method ([#580](https://github.com/kolkov/angular-editor/issues/580)) ([774a97d](https://github.com/kolkov/angular-editor/commit/774a97d)) 6 | - XSS could bypass sanitizer when setting editor value via ngModel/formControl 7 | - Sanitization now properly applied to all innerHTML assignments 8 | - Thanks to @MarioTesoro for responsible disclosure with PoC 9 | 10 | ### Bug Fixes 11 | * **links:** Preserve relative URLs when editing existing links ([#359](https://github.com/kolkov/angular-editor/issues/359)) ([c691d30](https://github.com/kolkov/angular-editor/commit/c691d30)) 12 | - Use `getAttribute('href')` instead of `.href` property 13 | - Prevents adding hostname to relative paths 14 | * **debug:** Remove debug `console.log` statement from focus() method ([#324](https://github.com/kolkov/angular-editor/issues/324)) ([c691d30](https://github.com/kolkov/angular-editor/commit/c691d30)) 15 | 16 | ### Upgrade Recommendation 17 | **IMMEDIATE UPGRADE RECOMMENDED** for all users. This release fixes a critical security vulnerability. 18 | 19 | --- 20 | 21 | 22 | ## [3.0.2](https://github.com/kolkov/angular-editor/compare/v3.0.1...v3.0.2) (2025-01-22) 23 | 24 | ### Bug Fixes 25 | * **toolbar:** toolbarHiddenButtons option now works without Bootstrap ([#544](https://github.com/kolkov/angular-editor/issues/544)) ([3563552](https://github.com/kolkov/angular-editor/commit/3563552)) 26 | * **image:** allow re-uploading same image after deletion ([#543](https://github.com/kolkov/angular-editor/issues/543), [#568](https://github.com/kolkov/angular-editor/issues/568), [#503](https://github.com/kolkov/angular-editor/issues/503)) ([7d21718](https://github.com/kolkov/angular-editor/commit/7d21718)) 27 | * **video:** support YouTube short URLs (youtu.be format) ([#557](https://github.com/kolkov/angular-editor/issues/557), [#554](https://github.com/kolkov/angular-editor/issues/554)) ([4aa8397](https://github.com/kolkov/angular-editor/commit/4aa8397)) 28 | 29 | ### Maintenance 30 | * **issues:** Systematic triage completed - 61 issues closed, 249 remain open 31 | * **documentation:** Added issue triage session record 32 | 33 | --- 34 | 35 | 36 | ## [3.0.1](https://github.com/kolkov/angular-editor/compare/v3.0.0...v3.0.1) (2025-11-22) 37 | 38 | ### Bug Fixes 39 | * **Icons:** Fixed list icons (unordered/ordered) display consistency in toolbar 40 | 41 | ### CI/CD 42 | * **GitHub Actions:** Added automated npm publishing workflow 43 | * **npm Publishing:** Configured Granular Access Token authentication 44 | * **GitHub Releases:** Automated release creation with changelog 45 | 46 | --- 47 | 48 | 49 | ## [3.0.0](https://github.com/kolkov/angular-editor/compare/v2.0.0...v3.0.0) (2025-11-22) Major Angular 20 Upgrade 50 | 51 | 🎉 **Stable Release** - Production Ready! 52 | 53 | ### Breaking Changes 54 | * **Angular Version:** Minimum required version is now Angular 20.0.0 55 | * **RxJS:** Requires RxJS 7.8.0 or higher (upgraded from 6.5.5) 56 | * **TypeScript:** Requires TypeScript 5.4 or higher 57 | * **zone.js:** Updated to 0.15.1 58 | 59 | ### Features 60 | * **Angular 20 Support:** Full compatibility with Angular 20.3.13 (v20-lts) 61 | * **Angular 21 Ready:** Forward compatible with Angular 21.x 62 | * **Modern Build System:** Updated to latest ng-packagr 20.3.2 63 | * **Enhanced Type Safety:** Improved TypeScript strict mode compliance 64 | * **Font Awesome Removed:** No external icon dependencies - using pure SVG icons (27 icons) 65 | * **Zero External Icon Dependencies:** Fully self-contained icon system 66 | 67 | ### Migration Path 68 | * Migrated through: Angular 13 → 18 → 19 → 20 69 | * All Angular CLI migrations applied successfully 70 | * Updated DOCUMENT import from @angular/core (Angular 20 requirement) 71 | * Modernized test infrastructure (waitForAsync) 72 | 73 | ### Developer Experience 74 | * **ESLint:** Updated to @angular-eslint 20.x 75 | * **Linting:** All files pass linting (0 errors) 76 | * **Build:** Both development and production builds verified 77 | * **Tests:** 13/13 tests passing (100% success rate) 78 | 79 | ### Bug Fixes 80 | * **Tests:** Fixed AeSelectComponent tests for mousedown event handling 81 | * **Demo:** Updated demo app for Angular 20 compatibility 82 | 83 | ### Technical Details 84 | * Removed deprecated `async` test helper (use `waitForAsync`) 85 | * Fixed TypeScript strict type checking for event handlers 86 | * Disabled new strict rules for backward compatibility (prefer-standalone, prefer-inject) 87 | * Updated moduleResolution to 'bundler' (Angular 20 standard) 88 | 89 | ### Peer Dependencies 90 | ```json 91 | { 92 | "@angular/common": "^20.0.0 || ^21.0.0", 93 | "@angular/core": "^20.0.0 || ^21.0.0", 94 | "@angular/forms": "^20.0.0 || ^21.0.0", 95 | "rxjs": "^7.8.0" 96 | } 97 | ``` 98 | 99 | 100 | ## [3.0.0-beta.2](https://github.com/kolkov/angular-editor/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2025-01-10) 101 | * Refactor ae-select component (button → span) 102 | 103 | 104 | ## [2.0.0](https://github.com/kolkov/angular-editor/compare/v1.2.0...v2.0.0) (2022-01-06) Major release 105 | * Update to Angular v.13 and new Ivy compatible package format 106 | 107 | 108 | ## [1.0.2](https://github.com/kolkov/angular-editor/compare/v1.0.1...v1.0.2) (2019-11-28) Technical release 109 | * Readme update for npmjs.com 110 | 111 | 112 | ## [1.0.1](https://github.com/kolkov/angular-editor/compare/v1.0.0...v1.0.1) (2019-11-27) Technical release 113 | * Fix logo at npmjs.com readme 114 | 115 | 116 | ## [1.0.0](https://github.com/kolkov/angular-editor/compare/v1.0.0-rc.2...v1.0.0) (2019-11-27) Initial release 117 | 118 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-editor": { 7 | "projectType": "library", 8 | "root": "projects/angular-editor", 9 | "sourceRoot": "projects/angular-editor/src", 10 | "prefix": "lib", 11 | "schematics": { 12 | "@schematics/angular:component": { 13 | "style": "scss" 14 | } 15 | }, 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:ng-packagr", 19 | "options": { 20 | "tsConfig": "projects/angular-editor/tsconfig.lib.json", 21 | "project": "projects/angular-editor/ng-package.json" 22 | }, 23 | "configurations": { 24 | "production": { 25 | "tsConfig": "projects/angular-editor/tsconfig.lib.prod.json" 26 | } 27 | } 28 | }, 29 | "test": { 30 | "builder": "@angular-devkit/build-angular:karma", 31 | "options": { 32 | "main": "projects/angular-editor/src/test.ts", 33 | "tsConfig": "projects/angular-editor/tsconfig.spec.json", 34 | "karmaConfig": "projects/angular-editor/karma.conf.js" 35 | } 36 | }, 37 | "lint": { 38 | "builder": "@angular-eslint/builder:lint", 39 | "options": { 40 | "lintFilePatterns": [ 41 | "projects/angular-editor/**/*.ts", 42 | "projects/angular-editor/**/*.html" 43 | ] 44 | } 45 | } 46 | } 47 | }, 48 | "angular-editor-app": { 49 | "projectType": "application", 50 | "schematics": { 51 | "@schematics/angular:component": { 52 | "style": "scss" 53 | } 54 | }, 55 | "root": "projects/angular-editor-app", 56 | "sourceRoot": "projects/angular-editor-app/src", 57 | "prefix": "app", 58 | "architect": { 59 | "build": { 60 | "builder": "@angular-devkit/build-angular:browser", 61 | "options": { 62 | "outputPath": "dist/angular-editor-app", 63 | "index": "projects/angular-editor-app/src/index.html", 64 | "main": "projects/angular-editor-app/src/main.ts", 65 | "polyfills": "projects/angular-editor-app/src/polyfills.ts", 66 | "tsConfig": "projects/angular-editor-app/tsconfig.app.json", 67 | "assets": [ 68 | "projects/angular-editor-app/src/favicon.ico", 69 | "projects/angular-editor-app/src/assets", 70 | { 71 | "glob": "**/*", 72 | "input": "projects/angular-editor/assets/icons/", 73 | "output": "assets/ae-icons/" 74 | } 75 | ], 76 | "styles": [ 77 | "projects/angular-editor-app/src/styles.scss", 78 | "projects/angular-editor/themes/default.scss" 79 | ], 80 | "scripts": [], 81 | "vendorChunk": true, 82 | "extractLicenses": false, 83 | "buildOptimizer": false, 84 | "sourceMap": true, 85 | "optimization": false, 86 | "namedChunks": true 87 | }, 88 | "configurations": { 89 | "production": { 90 | "fileReplacements": [ 91 | { 92 | "replace": "projects/angular-editor-app/src/environments/environment.ts", 93 | "with": "projects/angular-editor-app/src/environments/environment.prod.ts" 94 | } 95 | ], 96 | "optimization": true, 97 | "outputHashing": "all", 98 | "sourceMap": false, 99 | "namedChunks": false, 100 | "extractLicenses": true, 101 | "vendorChunk": false, 102 | "buildOptimizer": true, 103 | "budgets": [ 104 | { 105 | "type": "initial", 106 | "maximumWarning": "2mb", 107 | "maximumError": "5mb" 108 | }, 109 | { 110 | "type": "anyComponentStyle", 111 | "maximumWarning": "6kb" 112 | } 113 | ] 114 | } 115 | } 116 | }, 117 | "serve": { 118 | "builder": "@angular-devkit/build-angular:dev-server", 119 | "options": { 120 | "buildTarget": "angular-editor-app:build" 121 | }, 122 | "configurations": { 123 | "production": { 124 | "buildTarget": "angular-editor-app:build:production" 125 | } 126 | } 127 | }, 128 | "extract-i18n": { 129 | "builder": "@angular-devkit/build-angular:extract-i18n", 130 | "options": { 131 | "buildTarget": "angular-editor-app:build" 132 | } 133 | }, 134 | "test": { 135 | "builder": "@angular-devkit/build-angular:karma", 136 | "options": { 137 | "main": "projects/angular-editor-app/src/test.ts", 138 | "polyfills": "projects/angular-editor-app/src/polyfills.ts", 139 | "tsConfig": "projects/angular-editor-app/tsconfig.spec.json", 140 | "karmaConfig": "projects/angular-editor-app/karma.conf.js", 141 | "assets": [ 142 | "projects/angular-editor-app/src/favicon.ico", 143 | "projects/angular-editor-app/src/assets" 144 | ], 145 | "styles": [ 146 | "projects/angular-editor-app/src/styles.css" 147 | ], 148 | "scripts": [] 149 | } 150 | }, 151 | "lint": { 152 | "builder": "@angular-eslint/builder:lint", 153 | "options": { 154 | "lintFilePatterns": [ 155 | "projects/angular-editor-app/**/*.ts", 156 | "projects/angular-editor-app/**/*.html" 157 | ] 158 | } 159 | }, 160 | "e2e": { 161 | "builder": "@angular-devkit/build-angular:protractor", 162 | "options": { 163 | "protractorConfig": "projects/angular-editor-app/e2e/protractor.conf.js", 164 | "devServerTarget": "angular-editor-app:serve" 165 | }, 166 | "configurations": { 167 | "production": { 168 | "devServerTarget": "angular-editor-app:serve:production" 169 | } 170 | } 171 | } 172 | } 173 | } 174 | }, 175 | "cli": { 176 | "analytics": "fbddda2f-258b-4004-8062-d701809d0a1c", 177 | "schematicCollections": [ 178 | "@angular-eslint/schematics" 179 | ] 180 | }, 181 | "schematics": { 182 | "@schematics/angular:component": { 183 | "type": "component" 184 | }, 185 | "@schematics/angular:directive": { 186 | "type": "directive" 187 | }, 188 | "@schematics/angular:service": { 189 | "type": "service" 190 | }, 191 | "@schematics/angular:guard": { 192 | "typeSeparator": "." 193 | }, 194 | "@schematics/angular:interceptor": { 195 | "typeSeparator": "." 196 | }, 197 | "@schematics/angular:module": { 198 | "typeSeparator": "." 199 | }, 200 | "@schematics/angular:pipe": { 201 | "typeSeparator": "." 202 | }, 203 | "@schematics/angular:resolver": { 204 | "typeSeparator": "." 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-toolbar/ae-toolbar.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 5 | 8 |
9 |
10 | 13 | 16 | 19 | 23 | 26 | 30 |
31 |
32 | 36 | 40 | 44 | 48 |
49 |
50 | 54 | 58 |
59 |
60 | 64 | 68 |
69 |
70 | 76 |
77 |
78 | 84 |
85 |
86 | 92 | 93 |
94 |
95 | 99 | 104 | 108 | 113 |
114 |
115 | 121 |
122 |
123 | 126 | 129 | 134 | 138 | 142 | 146 |
147 |
148 | 152 |
153 |
154 | 157 |
158 | 159 |
160 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/angular-editor.service.ts: -------------------------------------------------------------------------------- 1 | import {Inject, Injectable, DOCUMENT} from '@angular/core'; 2 | import {HttpClient, HttpEvent} from '@angular/common/http'; 3 | import {Observable} from 'rxjs'; 4 | 5 | import {CustomClass} from './config'; 6 | 7 | export interface UploadResponse { 8 | imageUrl: string; 9 | } 10 | 11 | @Injectable() 12 | export class AngularEditorService { 13 | 14 | savedSelection: Range | null; 15 | selectedText: string; 16 | uploadUrl: string; 17 | uploadWithCredentials: boolean; 18 | 19 | constructor( 20 | private http: HttpClient, 21 | @Inject(DOCUMENT) private doc: any 22 | ) { } 23 | 24 | /** 25 | * Executed command from editor header buttons exclude toggleEditorMode 26 | * @param command string from triggerCommand 27 | * @param value 28 | */ 29 | executeCommand(command: string, value?: string) { 30 | const commands = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'pre']; 31 | if (commands.includes(command)) { 32 | this.doc.execCommand('formatBlock', false, command); 33 | return; 34 | } 35 | this.doc.execCommand(command, false, value); 36 | } 37 | 38 | /** 39 | * Create URL link 40 | * @param url string from UI prompt 41 | */ 42 | createLink(url: string) { 43 | if (!url.includes('http')) { 44 | this.doc.execCommand('createlink', false, url); 45 | } else { 46 | const newUrl = '' + this.selectedText + ''; 47 | this.insertHtml(newUrl); 48 | } 49 | } 50 | 51 | /** 52 | * insert color either font or background 53 | * 54 | * @param color color to be inserted 55 | * @param where where the color has to be inserted either text/background 56 | */ 57 | insertColor(color: string, where: string): void { 58 | const restored = this.restoreSelection(); 59 | if (restored) { 60 | if (where === 'textColor') { 61 | this.doc.execCommand('foreColor', false, color); 62 | } else { 63 | this.doc.execCommand('hiliteColor', false, color); 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Set font name 70 | * @param fontName string 71 | */ 72 | setFontName(fontName: string) { 73 | this.doc.execCommand('fontName', false, fontName); 74 | } 75 | 76 | /** 77 | * Set font size 78 | * @param fontSize string 79 | */ 80 | setFontSize(fontSize: string) { 81 | this.doc.execCommand('fontSize', false, fontSize); 82 | } 83 | 84 | /** 85 | * Create raw HTML 86 | * @param html HTML string 87 | */ 88 | insertHtml(html: string): void { 89 | 90 | const isHTMLInserted = this.doc.execCommand('insertHTML', false, html); 91 | 92 | if (!isHTMLInserted) { 93 | throw new Error('Unable to perform the operation'); 94 | } 95 | } 96 | 97 | /** 98 | * save selection when the editor is focussed out 99 | */ 100 | public saveSelection = (): void => { 101 | if (this.doc.getSelection) { 102 | const sel = this.doc.getSelection(); 103 | if (sel.getRangeAt && sel.rangeCount) { 104 | this.savedSelection = sel.getRangeAt(0); 105 | this.selectedText = sel.toString(); 106 | } 107 | } else if (this.doc.getSelection && this.doc.createRange) { 108 | this.savedSelection = document.createRange(); 109 | } else { 110 | this.savedSelection = null; 111 | } 112 | } 113 | 114 | /** 115 | * restore selection when the editor is focused in 116 | * 117 | * saved selection when the editor is focused out 118 | */ 119 | restoreSelection(): boolean { 120 | if (this.savedSelection) { 121 | if (this.doc.getSelection) { 122 | const sel = this.doc.getSelection(); 123 | sel.removeAllRanges(); 124 | sel.addRange(this.savedSelection); 125 | return true; 126 | } else if (this.doc.getSelection /*&& this.savedSelection.select*/) { 127 | // this.savedSelection.select(); 128 | return true; 129 | } 130 | } else { 131 | return false; 132 | } 133 | } 134 | 135 | /** 136 | * setTimeout used for execute 'saveSelection' method in next event loop iteration 137 | */ 138 | public executeInNextQueueIteration(callbackFn: (...args: any[]) => any, timeout = 1e2): void { 139 | setTimeout(callbackFn, timeout); 140 | } 141 | 142 | /** check any selection is made or not */ 143 | private checkSelection(): any { 144 | 145 | const selectedText = this.savedSelection.toString(); 146 | 147 | if (selectedText.length === 0) { 148 | throw new Error('No Selection Made'); 149 | } 150 | return true; 151 | } 152 | 153 | /** 154 | * Upload file to uploadUrl 155 | * @param file The file 156 | */ 157 | uploadImage(file: File): Observable> { 158 | 159 | const uploadData: FormData = new FormData(); 160 | 161 | uploadData.append('file', file, file.name); 162 | 163 | return this.http.post(this.uploadUrl, uploadData, { 164 | reportProgress: true, 165 | observe: 'events', 166 | withCredentials: this.uploadWithCredentials, 167 | }); 168 | } 169 | 170 | /** 171 | * Insert image with Url 172 | * @param imageUrl The imageUrl. 173 | */ 174 | insertImage(imageUrl: string) { 175 | this.doc.execCommand('insertImage', false, imageUrl); 176 | } 177 | 178 | setDefaultParagraphSeparator(separator: string) { 179 | this.doc.execCommand('defaultParagraphSeparator', false, separator); 180 | } 181 | 182 | createCustomClass(customClass: CustomClass) { 183 | let newTag = this.selectedText; 184 | if (customClass) { 185 | const tagName = customClass.tag ? customClass.tag : 'span'; 186 | newTag = '<' + tagName + ' class="' + customClass.class + '">' + this.selectedText + ''; 187 | } 188 | this.insertHtml(newTag); 189 | } 190 | 191 | insertVideo(videoUrl: string) { 192 | if (videoUrl.match('www.youtube.com') || videoUrl.match('youtu.be')) { 193 | this.insertYouTubeVideoTag(videoUrl); 194 | } 195 | if (videoUrl.match('vimeo.com')) { 196 | this.insertVimeoVideoTag(videoUrl); 197 | } 198 | } 199 | 200 | private insertYouTubeVideoTag(videoUrl: string): void { 201 | // Support both formats: youtube.com/watch?v=ID and youtu.be/ID 202 | let id: string; 203 | if (videoUrl.includes('youtu.be/')) { 204 | id = videoUrl.split('youtu.be/')[1].split('?')[0]; 205 | } else { 206 | id = videoUrl.split('v=')[1].split('&')[0]; 207 | } 208 | const imageUrl = `https://img.youtube.com/vi/${id}/0.jpg`; 209 | const thumbnail = ` 210 | `; 217 | this.insertHtml(thumbnail); 218 | } 219 | 220 | private insertVimeoVideoTag(videoUrl: string): void { 221 | const sub = this.http.get(`https://vimeo.com/api/oembed.json?url=${videoUrl}`).subscribe(data => { 222 | const imageUrl = data.thumbnail_url_with_play_button; 223 | const thumbnail = `
224 | 225 | ${data.title} 226 | 227 |
`; 228 | this.insertHtml(thumbnail); 229 | sub.unsubscribe(); 230 | }); 231 | } 232 | 233 | nextNode(node) { 234 | if (node.hasChildNodes()) { 235 | return node.firstChild; 236 | } else { 237 | while (node && !node.nextSibling) { 238 | node = node.parentNode; 239 | } 240 | if (!node) { 241 | return null; 242 | } 243 | return node.nextSibling; 244 | } 245 | } 246 | 247 | getRangeSelectedNodes(range, includePartiallySelectedContainers) { 248 | let node = range.startContainer; 249 | const endNode = range.endContainer; 250 | let rangeNodes = []; 251 | 252 | // Special case for a range that is contained within a single node 253 | if (node === endNode) { 254 | rangeNodes = [node]; 255 | } else { 256 | // Iterate nodes until we hit the end container 257 | while (node && node !== endNode) { 258 | rangeNodes.push( node = this.nextNode(node) ); 259 | } 260 | 261 | // Add partially selected nodes at the start of the range 262 | node = range.startContainer; 263 | while (node && node !== range.commonAncestorContainer) { 264 | rangeNodes.unshift(node); 265 | node = node.parentNode; 266 | } 267 | } 268 | 269 | // Add ancestors of the range container, if required 270 | if (includePartiallySelectedContainers) { 271 | node = range.commonAncestorContainer; 272 | while (node) { 273 | rangeNodes.push(node); 274 | node = node.parentNode; 275 | } 276 | } 277 | 278 | return rangeNodes; 279 | } 280 | 281 | getSelectedNodes() { 282 | const nodes = []; 283 | if (this.doc.getSelection) { 284 | const sel = this.doc.getSelection(); 285 | for (let i = 0, len = sel.rangeCount; i < len; ++i) { 286 | nodes.push.apply(nodes, this.getRangeSelectedNodes(sel.getRangeAt(i), true)); 287 | } 288 | } 289 | return nodes; 290 | } 291 | 292 | replaceWithOwnChildren(el) { 293 | const parent = el.parentNode; 294 | while (el.hasChildNodes()) { 295 | parent.insertBefore(el.firstChild, el); 296 | } 297 | parent.removeChild(el); 298 | } 299 | 300 | removeSelectedElements(tagNames) { 301 | const tagNamesArray = tagNames.toLowerCase().split(','); 302 | this.getSelectedNodes().forEach((node) => { 303 | if (node.nodeType === 1 && 304 | tagNamesArray.indexOf(node.tagName.toLowerCase()) > -1) { 305 | // Remove the node and replace it with its children 306 | this.replaceWithOwnChildren(node); 307 | } 308 | }); 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/ae-toolbar/ae-toolbar.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ElementRef, 4 | EventEmitter, 5 | Inject, 6 | Input, 7 | Output, 8 | Renderer2, 9 | ViewChild, 10 | ViewEncapsulation, 11 | DOCUMENT 12 | } from '@angular/core'; 13 | import {AngularEditorService, UploadResponse} from '../angular-editor.service'; 14 | import {HttpEvent, HttpResponse} from '@angular/common/http'; 15 | 16 | import {CustomClass} from '../config'; 17 | import {SelectOption} from '../ae-select/ae-select.component'; 18 | import {Observable} from 'rxjs'; 19 | 20 | @Component({ 21 | selector: 'angular-editor-toolbar, ae-toolbar, div[aeToolbar]', 22 | templateUrl: './ae-toolbar.component.html', 23 | styleUrls: ['./ae-toolbar.component.scss'], 24 | standalone: false 25 | }) 26 | 27 | export class AeToolbarComponent { 28 | htmlMode = false; 29 | linkSelected = false; 30 | block = 'default'; 31 | fontName = 'Times New Roman'; 32 | fontSize = '3'; 33 | foreColour; 34 | backColor; 35 | 36 | headings: SelectOption[] = [ 37 | { 38 | label: 'Heading 1', 39 | value: 'h1', 40 | }, 41 | { 42 | label: 'Heading 2', 43 | value: 'h2', 44 | }, 45 | { 46 | label: 'Heading 3', 47 | value: 'h3', 48 | }, 49 | { 50 | label: 'Heading 4', 51 | value: 'h4', 52 | }, 53 | { 54 | label: 'Heading 5', 55 | value: 'h5', 56 | }, 57 | { 58 | label: 'Heading 6', 59 | value: 'h6', 60 | }, 61 | { 62 | label: 'Paragraph', 63 | value: 'p', 64 | }, 65 | { 66 | label: 'Predefined', 67 | value: 'pre' 68 | }, 69 | { 70 | label: 'Standard', 71 | value: 'div' 72 | }, 73 | { 74 | label: 'default', 75 | value: 'default' 76 | } 77 | ]; 78 | 79 | fontSizes: SelectOption[] = [ 80 | { 81 | label: '1', 82 | value: '1', 83 | }, 84 | { 85 | label: '2', 86 | value: '2', 87 | }, 88 | { 89 | label: '3', 90 | value: '3', 91 | }, 92 | { 93 | label: '4', 94 | value: '4', 95 | }, 96 | { 97 | label: '5', 98 | value: '5', 99 | }, 100 | { 101 | label: '6', 102 | value: '6', 103 | }, 104 | { 105 | label: '7', 106 | value: '7', 107 | } 108 | ]; 109 | 110 | customClassId = '-1'; 111 | // eslint-disable-next-line no-underscore-dangle, id-blacklist, id-match 112 | _customClasses: CustomClass[]; 113 | customClassList: SelectOption[] = [{label: '', value: ''}]; 114 | // uploadUrl: string; 115 | 116 | tagMap = { 117 | BLOCKQUOTE: 'indent', 118 | A: 'link' 119 | }; 120 | 121 | select = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'PRE', 'DIV']; 122 | 123 | buttons = ['bold', 'italic', 'underline', 'strikeThrough', 'subscript', 'superscript', 'justifyLeft', 'justifyCenter', 124 | 'justifyRight', 'justifyFull', 'indent', 'outdent', 'insertUnorderedList', 'insertOrderedList', 'link']; 125 | 126 | @Input() id: string; 127 | @Input() uploadUrl: string; 128 | @Input() upload: (file: File) => Observable>; 129 | @Input() showToolbar: boolean; 130 | @Input() fonts: SelectOption[] = [{label: '', value: ''}]; 131 | 132 | @Input() 133 | set customClasses(classes: CustomClass[]) { 134 | if (classes) { 135 | this._customClasses = classes; 136 | this.customClassList = this._customClasses.map((x, i) => ({label: x.name, value: i.toString()})); 137 | this.customClassList.unshift({label: 'Clear Class', value: '-1'}); 138 | } 139 | } 140 | 141 | @Input() 142 | set defaultFontName(value: string) { 143 | if (value) { 144 | this.fontName = value; 145 | } 146 | } 147 | 148 | @Input() 149 | set defaultFontSize(value: string) { 150 | if (value) { 151 | this.fontSize = value; 152 | } 153 | } 154 | 155 | @Input() hiddenButtons: string[][]; 156 | 157 | @Output() execute: EventEmitter = new EventEmitter(); 158 | 159 | @ViewChild('fileInput', {static: true}) myInputFile: ElementRef; 160 | 161 | public get isLinkButtonDisabled(): boolean { 162 | return this.htmlMode || !Boolean(this.editorService.selectedText); 163 | } 164 | 165 | constructor( 166 | private r: Renderer2, 167 | private editorService: AngularEditorService, 168 | private er: ElementRef, 169 | @Inject(DOCUMENT) private doc: any 170 | ) { 171 | } 172 | 173 | /** 174 | * Trigger command from editor header buttons 175 | * @param command string from toolbar buttons 176 | */ 177 | triggerCommand(command: string) { 178 | this.execute.emit(command); 179 | } 180 | 181 | /** 182 | * highlight editor buttons when cursor moved or positioning 183 | */ 184 | triggerButtons() { 185 | if (!this.showToolbar) { 186 | return; 187 | } 188 | this.buttons.forEach(e => { 189 | const result = this.doc.queryCommandState(e); 190 | const elementById = this.doc.getElementById(e + '-' + this.id); 191 | if (result) { 192 | this.r.addClass(elementById, 'active'); 193 | } else { 194 | this.r.removeClass(elementById, 'active'); 195 | } 196 | }); 197 | } 198 | 199 | /** 200 | * trigger highlight editor buttons when cursor moved or positioning in block 201 | */ 202 | triggerBlocks(nodes: Node[]) { 203 | if (!this.showToolbar) { 204 | return; 205 | } 206 | this.linkSelected = nodes.findIndex(x => x.nodeName === 'A') > -1; 207 | let found = false; 208 | this.select.forEach(y => { 209 | const node = nodes.find(x => x.nodeName === y); 210 | if (node !== undefined && y === node.nodeName) { 211 | if (found === false) { 212 | this.block = node.nodeName.toLowerCase(); 213 | found = true; 214 | } 215 | } else if (found === false) { 216 | this.block = 'default'; 217 | } 218 | }); 219 | 220 | found = false; 221 | if (this._customClasses) { 222 | this._customClasses.forEach((y, index) => { 223 | const node = nodes.find(x => { 224 | if (x instanceof Element) { 225 | return x.className === y.class; 226 | } 227 | }); 228 | if (node !== undefined) { 229 | if (found === false) { 230 | this.customClassId = index.toString(); 231 | found = true; 232 | } 233 | } else if (found === false) { 234 | this.customClassId = '-1'; 235 | } 236 | }); 237 | } 238 | 239 | Object.keys(this.tagMap).map(e => { 240 | const elementById = this.doc.getElementById(this.tagMap[e] + '-' + this.id); 241 | const node = nodes.find(x => x.nodeName === e); 242 | if (node !== undefined && e === node.nodeName) { 243 | this.r.addClass(elementById, 'active'); 244 | } else { 245 | this.r.removeClass(elementById, 'active'); 246 | } 247 | }); 248 | 249 | this.foreColour = this.doc.queryCommandValue('ForeColor'); 250 | this.fontSize = this.doc.queryCommandValue('FontSize'); 251 | this.fontName = this.doc.queryCommandValue('FontName').replace(/"/g, ''); 252 | this.backColor = this.doc.queryCommandValue('backColor'); 253 | } 254 | 255 | /** 256 | * insert URL link 257 | */ 258 | insertUrl() { 259 | let url = 'https:\/\/'; 260 | const selection = this.editorService.savedSelection; 261 | if (selection && selection.commonAncestorContainer.parentElement.nodeName === 'A') { 262 | const parent = selection.commonAncestorContainer.parentElement as HTMLAnchorElement; 263 | // Use getAttribute to preserve relative URLs instead of href which returns absolute URL 264 | const href = parent.getAttribute('href'); 265 | if (href !== '' && href !== null) { 266 | url = href; 267 | } 268 | } 269 | url = prompt('Insert URL link', url); 270 | if (url && url !== '' && url !== 'https://') { 271 | this.editorService.createLink(url); 272 | } 273 | } 274 | 275 | /** 276 | * insert Video link 277 | */ 278 | insertVideo() { 279 | this.execute.emit(''); 280 | const url = prompt('Insert Video link', `https://`); 281 | if (url && url !== '' && url !== `https://`) { 282 | this.editorService.insertVideo(url); 283 | } 284 | } 285 | 286 | /** insert color */ 287 | insertColor(color: string, where: string) { 288 | this.editorService.insertColor(color, where); 289 | this.execute.emit(''); 290 | } 291 | 292 | /** 293 | * set font Name/family 294 | * @param foreColor string 295 | */ 296 | setFontName(foreColor: string): void { 297 | this.editorService.setFontName(foreColor); 298 | this.execute.emit(''); 299 | } 300 | 301 | /** 302 | * set font Size 303 | * @param fontSize string 304 | */ 305 | setFontSize(fontSize: string): void { 306 | this.editorService.setFontSize(fontSize); 307 | this.execute.emit(''); 308 | } 309 | 310 | /** 311 | * toggle editor mode (WYSIWYG or SOURCE) 312 | * @param m boolean 313 | */ 314 | setEditorMode(m: boolean) { 315 | const toggleEditorModeButton = this.doc.getElementById('toggleEditorMode' + '-' + this.id); 316 | if (m) { 317 | this.r.addClass(toggleEditorModeButton, 'active'); 318 | } else { 319 | this.r.removeClass(toggleEditorModeButton, 'active'); 320 | } 321 | this.htmlMode = m; 322 | } 323 | 324 | /** 325 | * Upload image when file is selected. 326 | */ 327 | onFileChanged(event) { 328 | const file = event.target.files[0]; 329 | if (file.type.includes('image/')) { 330 | if (this.upload) { 331 | this.upload(file).subscribe((response: HttpResponse) => this.watchUploadImage(response, event)); 332 | } else if (this.uploadUrl) { 333 | this.editorService.uploadImage(file).subscribe((response: HttpResponse) => this.watchUploadImage(response, event)); 334 | } else { 335 | const reader = new FileReader(); 336 | reader.onload = (e: ProgressEvent) => { 337 | const fr = e.currentTarget as FileReader; 338 | this.editorService.insertImage(fr.result.toString()); 339 | // Reset input value to allow re-uploading the same file 340 | event.target.value = null; 341 | }; 342 | reader.readAsDataURL(file); 343 | } 344 | } 345 | } 346 | 347 | watchUploadImage(response: HttpResponse<{ imageUrl: string }>, event) { 348 | const {imageUrl} = response.body; 349 | this.editorService.insertImage(imageUrl); 350 | event.srcElement.value = null; 351 | } 352 | 353 | /** 354 | * Set custom class 355 | */ 356 | setCustomClass(classId: string) { 357 | if (classId === '-1') { 358 | this.execute.emit('clear'); 359 | } else { 360 | this.editorService.createCustomClass(this._customClasses[+classId]); 361 | } 362 | } 363 | 364 | isButtonHidden(name: string): boolean { 365 | if (!name) { 366 | return false; 367 | } 368 | if (!(this.hiddenButtons instanceof Array)) { 369 | return false; 370 | } 371 | let result: any; 372 | for (const arr of this.hiddenButtons) { 373 | if (arr instanceof Array) { 374 | result = arr.find(item => item === name); 375 | } 376 | if (result) { 377 | break; 378 | } 379 | } 380 | return result !== undefined; 381 | } 382 | 383 | focus() { 384 | this.execute.emit('focus'); 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | AngularEditor logo 3 |

4 | 5 | # AngularEditor 6 | [![npm version](https://badge.fury.io/js/%40kolkov%2Fangular-editor.svg)](https://badge.fury.io/js/%40kolkov%2Fangular-editor) 7 | [![npm](https://img.shields.io/npm/v/@kolkov/angular-editor.svg)](https://www.npmjs.com/package/@kolkov/angular-editor) 8 | [![CI](https://github.com/kolkov/angular-editor/actions/workflows/publish.yml/badge.svg)](https://github.com/kolkov/angular-editor/actions/workflows/publish.yml) 9 | [![npm downloads](https://img.shields.io/npm/dm/@kolkov/angular-editor.svg)](https://www.npmjs.com/package/@kolkov/angular-editor) 10 | [![demo](https://img.shields.io/badge/demo-StackBlitz-blueviolet.svg)](https://stackblitz.com/edit/angular-editor-wysiwyg) 11 | [![](https://data.jsdelivr.com/v1/package/npm/@kolkov/angular-editor/badge?style=rounded)](https://www.jsdelivr.com/package/npm/@kolkov/angular-editor) 12 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 13 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/AndreyKolkov) 14 | 15 | A simple native WYSIWYG/Rich Text editor for Angular 20+ 16 | 17 | ![Nov-27-2019 17-26-29](https://user-images.githubusercontent.com/216412/69763434-259cd800-113b-11ea-918f-0565ebce0e48.gif) 18 | 19 | 20 | ## Demo 21 | [demo](https://angular-editor-wysiwyg.stackblitz.io/) | [See the code in StackBlitz](https://stackblitz.com/edit/angular-editor-wysiwyg). 22 | 23 | ## Getting Started 24 | 25 | ### Installation 26 | 27 | Install via [npm][npm] package manager 28 | 29 | ```bash 30 | npm install @kolkov/angular-editor --save 31 | ``` 32 | ### Versions 33 | 3.0.0 and above - for Angular v20+ (CSS variables, modern Angular 20) 34 | 35 | 2.0.0 and above - for Angular v13-19 36 | 37 | 1.0.0 and above - for Angular v8-12 38 | 39 | 0.18.4 and above - for Angular v7.x.x 40 | 41 | 0.15.x - for Angular v6.x.x 42 | 43 | **Note:** Version 3.0.0 requires: 44 | - Angular 20.0.0 or higher (also compatible with Angular 21) 45 | - RxJS 7.8.0 or higher 46 | - TypeScript 5.4 or higher 47 | 48 | Attention! `alpha` and `beta` versions may contain breaking changes. 49 | 50 | ### Usage 51 | 52 | Import `angular-editor` module 53 | 54 | ```js 55 | import { HttpClientModule} from '@angular/common/http'; 56 | import { AngularEditorModule } from '@kolkov/angular-editor'; 57 | 58 | @NgModule({ 59 | imports: [ HttpClientModule, AngularEditorModule ] 60 | }) 61 | ``` 62 | 63 | Then in HTML 64 | 65 | ```html 66 | 67 | ``` 68 | 69 | or for usage with reactive forms 70 | 71 | ```html 72 | 73 | ``` 74 | 75 | if you are using more than one editor on same page set `id` property 76 | 77 | ```html 78 | 79 | 80 | ``` 81 | 82 | where 83 | 84 | ```js 85 | import { AngularEditorConfig } from '@kolkov/angular-editor'; 86 | 87 | 88 | editorConfig: AngularEditorConfig = { 89 | editable: true, 90 | spellcheck: true, 91 | height: 'auto', 92 | minHeight: '0', 93 | maxHeight: 'auto', 94 | width: 'auto', 95 | minWidth: '0', 96 | translate: 'yes', 97 | enableToolbar: true, 98 | showToolbar: true, 99 | placeholder: 'Enter text here...', 100 | defaultParagraphSeparator: '', 101 | defaultFontName: '', 102 | defaultFontSize: '', 103 | fonts: [ 104 | {class: 'arial', name: 'Arial'}, 105 | {class: 'times-new-roman', name: 'Times New Roman'}, 106 | {class: 'calibri', name: 'Calibri'}, 107 | {class: 'comic-sans-ms', name: 'Comic Sans MS'} 108 | ], 109 | customClasses: [ 110 | { 111 | name: 'quote', 112 | class: 'quote', 113 | }, 114 | { 115 | name: 'redText', 116 | class: 'redText' 117 | }, 118 | { 119 | name: 'titleText', 120 | class: 'titleText', 121 | tag: 'h1', 122 | }, 123 | ], 124 | uploadUrl: 'v1/image', 125 | upload: (file: File) => { ... } 126 | uploadWithCredentials: false, 127 | sanitize: true, 128 | toolbarPosition: 'top', 129 | toolbarHiddenButtons: [ 130 | ['bold', 'italic'], 131 | ['fontSize'] 132 | ] 133 | }; 134 | ``` 135 | For `ngModel` to work, you must import `FormsModule` from `@angular/forms`, or for `formControlName`, you must import `ReactiveFormsModule` from `@angular/forms` 136 | 137 | To serve the icons file, ensure that your `angular.json` contains the following asset configuration: 138 | 139 | ``` 140 | { 141 | "glob": "**/*", 142 | "input": "node_modules/@kolkov/angular-editor/assets/icons", 143 | "output": "assets/ae-icons/" 144 | } 145 | ``` 146 | 147 | ### Styling 148 | 149 | Connect default theme file to your `angular.json` or `nx.json` 150 | ``` 151 | "styles": [ 152 | "projects/angular-editor-app/src/styles.scss", 153 | "node_modules/@kolkov/angular-editor/themes/default.scss" 154 | ], 155 | ``` 156 | or `@include` or `@use` in your project `styles.scss` file, and then override default theme variables like this: 157 | ```scss 158 | :root { 159 | --ae-gap: 5px; 160 | --ae-text-area-border: 1px solid #ddd; 161 | --ae-text-area-border-radius: 0; 162 | --ae-focus-outline-color: #afaeae auto 1px; 163 | --ae-toolbar-padding: 1px; 164 | --ae-toolbar-bg-color: #b3dca0; 165 | --ae-toolbar-border-radius: 1px solid #ddd; 166 | --ae-button-bg-color: #dadad7; 167 | --ae-button-border: 3px solid #3fb74e; 168 | --ae-button-radius: 5px; 169 | --ae-button-hover-bg-color: #3fb74e; 170 | --ae-button-active-bg-color: red; 171 | --ae-button-active-hover-bg-color: blue; 172 | --ae-button-disabled-bg-color: gray; 173 | --ae-picker-label-color: rgb(78, 84, 155); 174 | --ae-picker-icon-bg-color: rgb(34, 41, 122); 175 | --ae-picker-option-bg-color: rgba(221, 221, 84, 0.76); 176 | --ae-picker-option-active-bg-color: rgba(237, 237, 62, 0.9); 177 | --ae-picker-option-focused-bg-color: rgb(255, 255, 0); 178 | } 179 | ``` 180 | 181 | ### Custom buttons 182 | 183 | You can define your custom buttons with custom actions using executeCommandFn. It accepts commands from [execCommand](https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand). 184 | The first argument of this method is aCommandName and the second argument is aValueArgument. Example shows a button that adds Angular editor logo into the editor. 185 | ```html 186 | 188 | 189 | 190 | 210 | 211 | 212 | 213 | ``` 214 | 215 | ## API 216 | ### Inputs 217 | | Input | Type | Default | Required | Description | 218 | | ------------- | ------------- | ------------- | ------------- | ------------- | 219 | | id | `string` | `-` | no | Id property when multiple editor used on same page | 220 | | [config] | `AngularEditorConfig` | `default config` | no | config for the editor | 221 | | placeholder | `string` | `-` | no | Set custom placeholder for input area | 222 | | tabIndex | `number` | `-` | no | Set Set tabindex on angular-editor | 223 | 224 | ### Outputs 225 | 226 | | Output | Description | 227 | | ------------- | ------------- | 228 | | (html) | Output html | 229 | | (viewMode) | Fired when switched visual and html source mode | 230 | | (blur) | Fired when editor blur | 231 | | (focus) | Fired when editor focus | 232 | 233 | ### Methods 234 | Name | Description | 235 | | ------------- | ------------- | 236 | | focus | Focuses the editor element | 237 | 238 | ### Other 239 | Name | Type | Description | 240 | | ------------- | ------------- | ------------- | 241 | | AngularEditorConfig | configuration | Configuration for the AngularEditor component.| 242 | 243 | ### Configuration 244 | 245 | | Input | Type | Default | Required | Description | 246 | | ------------- | ------------- | ------------- | ------------- | ------------- | 247 | | editable | `bolean` | `true` | no | Set editing enabled or not | 248 | | spellcheck | `bolean` | `true` | no | Set spellchecking enabled or not | 249 | | translate | `string` | `yes` | no | Set translating enabled or not | 250 | | sanitize | `bolean` | `true` | no | Set DOM sanitizing enabled or not | 251 | | height | `string` | `auto` | no | Set height of the editor | 252 | | minHeight | `string` | `0` | no | Set minimum height of the editor | 253 | | maxHeight | `string` | `auto` | no | Set maximum height of the editor | 254 | | width | `string` | `auto` | no | Set width of the editor | 255 | | minWidth | `string` | `0` | no | Set minimum width of the editor | 256 | | enableToolbar | `bolean` | `true` | no | Set toolbar enabled or not | 257 | | showToolbar | `bolean` | `true` | no | Set toolbar visible or not | 258 | | toolbarPosition | `string` | `top` | no | Set toolbar position top or bottom | 259 | | placeholder | `string` | `-` | no | Set placeholder text | 260 | | defaultParagraphSeparator | `string` | `-` | no | Set default paragraph separator such as `p` | 261 | | defaultFontName | `string` | `-` | no | Set default font such as `Comic Sans MS` | 262 | | defaultFontSize | `string` | `-` | no | Set default font size such as `1` - `7` | 263 | | uploadUrl | `string` | `-` | no | Set image upload endpoint `https://api.exapple.com/v1/image/upload` and return response with imageUrl key. {"imageUrl" : } | 264 | | upload | `function` | `-` | no | Set image upload function | 265 | | uploadWithCredentials | `bolean` | `false` | no | Set passing or not credentials in the image upload call | 266 | | fonts | `Font[]` | `-` | no | Set array of available fonts `[{name, class},...]` | 267 | | customClasses | `CustomClass[]` | `-` | no | Set array of available fonts `[{name, class, tag},...]` | 268 | | outline | `bolean` | `true` | no | Set outline of the editor if in focus | 269 | | toolbarHiddenButtons | `string[][]` | `-` | no | Set of the array of button names or elements to hide | 270 | 271 | ```js 272 | toolbarHiddenButtons: [ 273 | [ 274 | 'undo', 275 | 'redo', 276 | 'bold', 277 | 'italic', 278 | 'underline', 279 | 'strikeThrough', 280 | 'subscript', 281 | 'superscript', 282 | 'justifyLeft', 283 | 'justifyCenter', 284 | 'justifyRight', 285 | 'justifyFull', 286 | 'indent', 287 | 'outdent', 288 | 'insertUnorderedList', 289 | 'insertOrderedList', 290 | 'heading', 291 | 'fontName' 292 | ], 293 | [ 294 | 'fontSize', 295 | 'textColor', 296 | 'backgroundColor', 297 | 'customClasses', 298 | 'link', 299 | 'unlink', 300 | 'insertImage', 301 | 'insertVideo', 302 | 'insertHorizontalRule', 303 | 'removeFormat', 304 | 'toggleEditorMode' 305 | ] 306 | ] 307 | ``` 308 | 309 | ## What's included 310 | 311 | Within the download you'll find the following directories and files. You'll see something like this: 312 | 313 | ``` 314 | angular-editor/ 315 | └── projects/ 316 | ├── angular-editor/ 317 | └── angular-editor-app/ 318 | ``` 319 | `angular-editor/` - library 320 | 321 | `angular-editor-app/` - demo application 322 | 323 | ## Documentation 324 | 325 | The documentation for the AngularEditor is hosted at our website [AngularEditor](https://angular-editor.kolkov.ru/) 326 | 327 | Icons from Ligature Symbols Icons Collection [icons] 328 | 329 | ## Contributing 330 | 331 | Please read through our [contributing guidelines](https://github.com/kolkov/angular-editor/blob/main/CONTRIBUTING.md). Included are directions for opening issues, coding standards, and notes on development. 332 | 333 | Editor preferences are available in the [editor config](https://github.com/kolkov/angular-editor/blob/main/.editorconfig) for easy use in common text editors. Read more and download plugins at . 334 | 335 | ## Versioning 336 | 337 | For a transparency into our release cycle and in striving to maintain backward compatibility, AngularEditor is maintained under [the Semantic Versioning guidelines](http://semver.org/). 338 | 339 | See [the Releases section of our project](https://github.com/kolkov/angular-editor/releases) for changelogs for each release version. 340 | 341 | ## Creators 342 | 343 | **Andrey Kolkov** 344 | 345 | * 346 | 347 | ## Donate 348 | 349 | If you like my work and I save your time you can buy me a :beer: or :pizza: [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/AndreyKolkov) 350 | 351 | [npm]: https://www.npmjs.com/package/@kolkov/angular-editor 352 | [demo]: https://angular-editor-wysiwyg.stackblitz.io/ 353 | [example]: https://stackblitz.com/edit/angular-editor-wysiwyg 354 | [icons]: https://www.svgrepo.com/collection/ligature-symbols-icons/ 355 | -------------------------------------------------------------------------------- /projects/angular-editor/src/lib/editor/angular-editor.component.ts: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | AfterViewInit, 4 | Attribute, 5 | ChangeDetectorRef, 6 | Component, 7 | ContentChild, 8 | ElementRef, 9 | EventEmitter, 10 | forwardRef, 11 | HostBinding, 12 | HostListener, 13 | Inject, 14 | Input, 15 | OnDestroy, 16 | OnInit, 17 | Output, 18 | Renderer2, 19 | SecurityContext, 20 | TemplateRef, 21 | ViewChild, ViewEncapsulation, 22 | DOCUMENT 23 | } from '@angular/core'; 24 | import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; 25 | import {DomSanitizer} from '@angular/platform-browser'; 26 | import {AeToolbarComponent} from '../ae-toolbar/ae-toolbar.component'; 27 | import {AngularEditorService} from '../angular-editor.service'; 28 | import {AngularEditorConfig, angularEditorConfig} from '../config'; 29 | import {isDefined} from '../utils'; 30 | 31 | @Component({ 32 | selector: 'angular-editor', 33 | templateUrl: './angular-editor.component.html', 34 | styleUrls: ['./angular-editor.component.scss'], 35 | encapsulation: ViewEncapsulation.None, 36 | providers: [ 37 | { 38 | provide: NG_VALUE_ACCESSOR, 39 | useExisting: forwardRef(() => AngularEditorComponent), 40 | multi: true 41 | }, 42 | AngularEditorService 43 | ], 44 | standalone: false 45 | }) 46 | export class AngularEditorComponent implements OnInit, ControlValueAccessor, AfterViewInit, OnDestroy { 47 | 48 | private onChange: (value: string) => void; 49 | private onTouched: () => void; 50 | 51 | modeVisual = true; 52 | showPlaceholder = false; 53 | disabled = false; 54 | focused = false; 55 | touched = false; 56 | changed = false; 57 | 58 | focusInstance: any; 59 | blurInstance: any; 60 | 61 | @Input() id = ''; 62 | @Input() config: AngularEditorConfig = angularEditorConfig; 63 | @Input() placeholder = ''; 64 | @Input() tabIndex: number | null; 65 | 66 | @Output() html; 67 | 68 | @ViewChild('editor', {static: true}) textArea: ElementRef; 69 | @ViewChild('editorWrapper', {static: true}) editorWrapper: ElementRef; 70 | @ViewChild('editorToolbar') editorToolbar: AeToolbarComponent; 71 | @ContentChild("customButtons") customButtonsTemplateRef?: TemplateRef; 72 | executeCommandFn = this.executeCommand.bind(this); 73 | 74 | @Output() viewMode = new EventEmitter(); 75 | 76 | /** emits `blur` event when focused out from the textarea */ 77 | // eslint-disable-next-line @angular-eslint/no-output-native, @angular-eslint/no-output-rename 78 | @Output('blur') blurEvent: EventEmitter = new EventEmitter(); 79 | 80 | /** emits `focus` event when focused in to the textarea */ 81 | // eslint-disable-next-line @angular-eslint/no-output-rename, @angular-eslint/no-output-native 82 | @Output('focus') focusEvent: EventEmitter = new EventEmitter(); 83 | 84 | @HostBinding('attr.tabindex') tabindex = -1; 85 | 86 | @HostListener('focus') 87 | onFocus() { 88 | this.focus(); 89 | } 90 | 91 | constructor( 92 | private r: Renderer2, 93 | private editorService: AngularEditorService, 94 | @Inject(DOCUMENT) private doc: any, 95 | private sanitizer: DomSanitizer, 96 | private cdRef: ChangeDetectorRef, 97 | @Attribute('tabindex') defaultTabIndex: string, 98 | @Attribute('autofocus') private autoFocus: any 99 | ) { 100 | const parsedTabIndex = Number(defaultTabIndex); 101 | this.tabIndex = (parsedTabIndex || parsedTabIndex === 0) ? parsedTabIndex : null; 102 | } 103 | 104 | ngOnInit() { 105 | this.config.toolbarPosition = this.config.toolbarPosition ? this.config.toolbarPosition : angularEditorConfig.toolbarPosition; 106 | } 107 | 108 | ngAfterViewInit() { 109 | if (isDefined(this.autoFocus)) { 110 | this.focus(); 111 | } 112 | } 113 | 114 | onPaste(event: ClipboardEvent) { 115 | if (this.config.rawPaste) { 116 | event.preventDefault(); 117 | const text = event.clipboardData.getData('text/plain'); 118 | document.execCommand('insertHTML', false, text); 119 | return text; 120 | } 121 | } 122 | 123 | /** 124 | * Executed command from editor header buttons 125 | * @param command string from triggerCommand 126 | * @param value 127 | */ 128 | executeCommand(command: string, value?: string) { 129 | this.focus(); 130 | if (command === 'focus') { 131 | return; 132 | } 133 | if (command === 'toggleEditorMode') { 134 | this.toggleEditorMode(this.modeVisual); 135 | } else if (command !== '') { 136 | if (command === 'clear') { 137 | this.editorService.removeSelectedElements(this.getCustomTags()); 138 | this.onContentChange(this.textArea.nativeElement); 139 | } else if (command === 'default') { 140 | this.editorService.removeSelectedElements('h1,h2,h3,h4,h5,h6,p,pre'); 141 | this.onContentChange(this.textArea.nativeElement); 142 | } else { 143 | this.editorService.executeCommand(command, value); 144 | } 145 | this.exec(); 146 | } 147 | } 148 | 149 | /** 150 | * focus event 151 | */ 152 | onTextAreaFocus(event: FocusEvent): void { 153 | if (this.focused) { 154 | event.stopPropagation(); 155 | return; 156 | } 157 | this.focused = true; 158 | this.focusEvent.emit(event); 159 | if (!this.touched || !this.changed) { 160 | this.editorService.executeInNextQueueIteration(() => { 161 | this.configure(); 162 | this.touched = true; 163 | }); 164 | } 165 | } 166 | 167 | /** 168 | * @description fires when cursor leaves textarea 169 | */ 170 | public onTextAreaMouseOut(event: MouseEvent): void { 171 | this.editorService.saveSelection(); 172 | } 173 | 174 | /** 175 | * blur event 176 | */ 177 | onTextAreaBlur(event: FocusEvent) { 178 | /** 179 | * save selection if focussed out 180 | */ 181 | this.editorService.executeInNextQueueIteration(this.editorService.saveSelection); 182 | 183 | if (typeof this.onTouched === 'function') { 184 | this.onTouched(); 185 | } 186 | 187 | if (event.relatedTarget !== null) { 188 | const parent = (event.relatedTarget as HTMLElement).parentElement; 189 | if (!parent.classList.contains('angular-editor-toolbar-set') && !parent.classList.contains('ae-picker')) { 190 | this.blurEvent.emit(event); 191 | this.focused = false; 192 | } 193 | } 194 | } 195 | 196 | /** 197 | * focus the text area when the editor is focused 198 | */ 199 | focus() { 200 | if (this.modeVisual) { 201 | this.textArea.nativeElement.focus(); 202 | } else { 203 | const sourceText = this.doc.getElementById('sourceText' + this.id); 204 | sourceText.focus(); 205 | this.focused = true; 206 | } 207 | } 208 | 209 | /** 210 | * Executed from the contenteditable section while the input property changes 211 | * @param element html element from contenteditable 212 | */ 213 | onContentChange(element: HTMLElement): void { 214 | let html = ''; 215 | if (this.modeVisual) { 216 | html = element.innerHTML; 217 | } else { 218 | html = element.innerText; 219 | } 220 | if ((!html || html === '
')) { 221 | html = ''; 222 | } 223 | if (typeof this.onChange === 'function') { 224 | this.onChange(this.config.sanitize || this.config.sanitize === undefined ? 225 | this.sanitizer.sanitize(SecurityContext.HTML, html) : html); 226 | if ((!html) !== this.showPlaceholder) { 227 | this.togglePlaceholder(this.showPlaceholder); 228 | } 229 | } 230 | this.changed = true; 231 | } 232 | 233 | /** 234 | * Set the function to be called 235 | * when the control receives a change event. 236 | * 237 | * @param fn a function 238 | */ 239 | registerOnChange(fn: any): void { 240 | this.onChange = e => (e === '
' ? fn('') : fn(e)); 241 | } 242 | 243 | /** 244 | * Set the function to be called 245 | * when the control receives a touch event. 246 | * 247 | * @param fn a function 248 | */ 249 | registerOnTouched(fn: any): void { 250 | this.onTouched = fn; 251 | } 252 | 253 | /** 254 | * Write a new value to the element. 255 | * 256 | * @param value value to be executed when there is a change in contenteditable 257 | */ 258 | writeValue(value: any): void { 259 | 260 | if ((!value || value === '
' || value === '') !== this.showPlaceholder) { 261 | this.togglePlaceholder(this.showPlaceholder); 262 | } 263 | 264 | if (value === undefined || value === '' || value === '
') { 265 | value = null; 266 | } 267 | 268 | this.refreshView(value); 269 | } 270 | 271 | /** 272 | * refresh view/HTML of the editor 273 | * 274 | * @param value html string from the editor 275 | */ 276 | refreshView(value: string): void { 277 | const normalizedValue = value === null ? '' : value; 278 | // Apply sanitization to prevent XSS when setting innerHTML 279 | const sanitizedValue = this.config.sanitize !== false 280 | ? this.sanitizer.sanitize(SecurityContext.HTML, normalizedValue) 281 | : normalizedValue; 282 | this.r.setProperty(this.textArea.nativeElement, 'innerHTML', sanitizedValue); 283 | 284 | return; 285 | } 286 | 287 | /** 288 | * toggles placeholder based on input string 289 | * 290 | * @param value A HTML string from the editor 291 | */ 292 | togglePlaceholder(value: boolean): void { 293 | if (!value) { 294 | this.r.addClass(this.editorWrapper.nativeElement, 'show-placeholder'); 295 | this.showPlaceholder = true; 296 | 297 | } else { 298 | this.r.removeClass(this.editorWrapper.nativeElement, 'show-placeholder'); 299 | this.showPlaceholder = false; 300 | } 301 | } 302 | 303 | /** 304 | * Implements disabled state for this element 305 | * 306 | * @param isDisabled Disabled flag 307 | */ 308 | setDisabledState(isDisabled: boolean): void { 309 | const div = this.textArea.nativeElement; 310 | const action = isDisabled ? 'addClass' : 'removeClass'; 311 | this.r[action](div, 'disabled'); 312 | this.disabled = isDisabled; 313 | } 314 | 315 | /** 316 | * toggles editor mode based on bToSource bool 317 | * 318 | * @param bToSource A boolean value from the editor 319 | */ 320 | toggleEditorMode(bToSource: boolean) { 321 | let oContent: any; 322 | const editableElement = this.textArea.nativeElement; 323 | 324 | if (bToSource) { 325 | oContent = this.r.createText(editableElement.innerHTML); 326 | this.r.setProperty(editableElement, 'innerHTML', ''); 327 | this.r.setProperty(editableElement, 'contentEditable', false); 328 | 329 | const oPre = this.r.createElement('pre'); 330 | this.r.setStyle(oPre, 'margin', '0'); 331 | this.r.setStyle(oPre, 'outline', 'none'); 332 | 333 | const oCode = this.r.createElement('code'); 334 | this.r.setProperty(oCode, 'id', 'sourceText' + this.id); 335 | this.r.setStyle(oCode, 'display', 'block'); 336 | this.r.setStyle(oCode, 'white-space', 'pre-wrap'); 337 | this.r.setStyle(oCode, 'word-break', 'keep-all'); 338 | this.r.setStyle(oCode, 'outline', 'none'); 339 | this.r.setStyle(oCode, 'margin', '0'); 340 | this.r.setStyle(oCode, 'background-color', '#fff5b9'); 341 | this.r.setProperty(oCode, 'contentEditable', true); 342 | this.r.appendChild(oCode, oContent); 343 | this.focusInstance = this.r.listen(oCode, 'focus', (event) => this.onTextAreaFocus(event)); 344 | this.blurInstance = this.r.listen(oCode, 'blur', (event) => this.onTextAreaBlur(event)); 345 | this.r.appendChild(oPre, oCode); 346 | this.r.appendChild(editableElement, oPre); 347 | 348 | // ToDo move to service 349 | this.doc.execCommand('defaultParagraphSeparator', false, 'div'); 350 | 351 | this.modeVisual = false; 352 | this.viewMode.emit(false); 353 | oCode.focus(); 354 | } else { 355 | if (this.doc.querySelectorAll) { 356 | this.r.setProperty(editableElement, 'innerHTML', editableElement.innerText); 357 | } else { 358 | oContent = this.doc.createRange(); 359 | oContent.selectNodeContents(editableElement.firstChild); 360 | this.r.setProperty(editableElement, 'innerHTML', oContent.toString()); 361 | } 362 | this.r.setProperty(editableElement, 'contentEditable', true); 363 | this.modeVisual = true; 364 | this.viewMode.emit(true); 365 | this.onContentChange(editableElement); 366 | editableElement.focus(); 367 | } 368 | this.editorToolbar.setEditorMode(!this.modeVisual); 369 | } 370 | 371 | /** 372 | * toggles editor buttons when cursor moved or positioning 373 | * 374 | * Send a node array from the contentEditable of the editor 375 | */ 376 | exec() { 377 | this.editorToolbar.triggerButtons(); 378 | 379 | let userSelection; 380 | if (this.doc.getSelection) { 381 | userSelection = this.doc.getSelection(); 382 | this.editorService.executeInNextQueueIteration(this.editorService.saveSelection); 383 | } 384 | 385 | let a = userSelection.focusNode; 386 | const els = []; 387 | while (a && a.id !== 'editor') { 388 | els.unshift(a); 389 | a = a.parentNode; 390 | } 391 | this.editorToolbar.triggerBlocks(els); 392 | } 393 | 394 | private configure() { 395 | this.editorService.uploadUrl = this.config.uploadUrl; 396 | this.editorService.uploadWithCredentials = this.config.uploadWithCredentials; 397 | if (this.config.defaultParagraphSeparator) { 398 | this.editorService.setDefaultParagraphSeparator(this.config.defaultParagraphSeparator); 399 | } 400 | if (this.config.defaultFontName) { 401 | this.editorService.setFontName(this.config.defaultFontName); 402 | } 403 | if (this.config.defaultFontSize) { 404 | this.editorService.setFontSize(this.config.defaultFontSize); 405 | } 406 | } 407 | 408 | getFonts() { 409 | const fonts = this.config.fonts ? this.config.fonts : angularEditorConfig.fonts; 410 | return fonts.map(x => { 411 | return {label: x.name, value: x.name}; 412 | }); 413 | } 414 | 415 | getCustomTags() { 416 | const tags = ['span']; 417 | this.config.customClasses.forEach(x => { 418 | if (x.tag !== undefined) { 419 | if (!tags.includes(x.tag)) { 420 | tags.push(x.tag); 421 | } 422 | } 423 | }); 424 | return tags.join(','); 425 | } 426 | 427 | ngOnDestroy() { 428 | if (this.blurInstance) { 429 | this.blurInstance(); 430 | } 431 | if (this.focusInstance) { 432 | this.focusInstance(); 433 | } 434 | } 435 | 436 | filterStyles(html: string): string { 437 | html = html.replace('position: fixed;', ''); 438 | return html; 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /projects/angular-editor/assets/icons/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 12 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 31 | 32 | 33 | 34 | 36 | 37 | 38 | 40 | 41 | 42 | 44 | 45 | 46 | 48 | 49 | 50 | 52 | 53 | 54 | 56 | 57 | 58 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 90 | 91 | 92 | 93 | 95 | 96 | 97 | 98 | 100 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 120 | 121 | 122 | 123 | 124 | 125 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 140 | 141 | 142 | 143 | 144 | 147 | 148 | 149 | 150 | 151 | 154 | 155 | 156 | 157 | --------------------------------------------------------------------------------