├── .changeset ├── README.md └── config.json ├── .circleci └── config.yml ├── .coveralls.yml ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── README.zh-CN.md ├── angular.json ├── commitlint.config.js ├── custom-types.d.ts ├── demo ├── app │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── components │ │ ├── button │ │ │ └── button.component.ts │ │ ├── editable-button │ │ │ └── editable-button.component.ts │ │ ├── editable-void │ │ │ ├── editable-void.component.html │ │ │ ├── editable-void.component.scss │ │ │ └── editable-void.component.ts │ │ ├── image │ │ │ └── image-component.ts │ │ ├── link │ │ │ └── link.component.ts │ │ ├── text │ │ │ └── text.component.ts │ │ └── video │ │ │ ├── video.component.html │ │ │ ├── video.component.scss │ │ │ └── video.component.ts │ ├── editable-voids │ │ ├── editable-voids.component.html │ │ ├── editable-voids.component.scss │ │ └── editable-voids.component.ts │ ├── embeds │ │ ├── embeds.component.html │ │ ├── embeds.component.scss │ │ └── embeds.component.ts │ ├── huge-document │ │ ├── huge-document.component.html │ │ └── huge-document.component.ts │ ├── images │ │ ├── images.component.html │ │ └── images.component.ts │ ├── inlines │ │ ├── inlines.component.html │ │ └── inlines.component.ts │ ├── markdown-shorcuts │ │ ├── markdown-shortcuts.component.html │ │ └── markdown-shortcuts.component.ts │ ├── mentions │ │ ├── mentions.component.html │ │ ├── mentions.component.scss │ │ └── mentions.component.ts │ ├── placeholder │ │ └── placeholder.component.ts │ ├── plugins │ │ └── block-cards.plugin.ts │ ├── readonly │ │ └── readonly.component.ts │ ├── richtext │ │ ├── richtext.component.html │ │ └── richtext.component.ts │ ├── search-highlighting │ │ ├── leaf.component.ts │ │ ├── search-highlighting.component.html │ │ ├── search-highlighting.component.scss │ │ └── search-highlighting.component.ts │ └── tables │ │ ├── tables.component.html │ │ └── tables.component.ts ├── assets │ ├── logo.svg │ ├── materialicons │ │ ├── flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2 │ │ └── icon.css │ ├── photo.jpeg │ └── text.svg ├── editor-typo.scss ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.scss ├── test.ts ├── tsconfig.app.json └── tsconfig.spec.json ├── docs ├── compatible.md └── images │ └── banner.jpeg ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.json ├── karma.conf.js ├── nginx.conf ├── package-lock.json ├── package.json ├── packages ├── CHANGELOG.md ├── README.md ├── karma.conf.js ├── ng-package.json ├── package.json ├── src │ ├── components │ │ ├── block-card │ │ │ ├── block-card.component.html │ │ │ ├── block-card.component.scss │ │ │ ├── block-card.component.spec.ts │ │ │ └── block-card.component.ts │ │ ├── children │ │ │ ├── children-outlet.component.ts │ │ │ └── children.component.ts │ │ ├── editable │ │ │ ├── editable.component.html │ │ │ ├── editable.component.spec.ts │ │ │ └── editable.component.ts │ │ ├── element │ │ │ ├── default-element.component.token.ts │ │ │ ├── default-element.component.ts │ │ │ └── element.component.ts │ │ ├── leaf │ │ │ ├── default-leaf.component.ts │ │ │ └── token.ts │ │ ├── leaves │ │ │ └── leaves.component.ts │ │ ├── string │ │ │ ├── default-string.component.ts │ │ │ ├── string.component.spec.ts │ │ │ ├── string.component.ts │ │ │ ├── template.component.html │ │ │ └── template.component.ts │ │ └── text │ │ │ ├── default-text.component.ts │ │ │ ├── token.ts │ │ │ └── void-text.component.ts │ ├── custom-event │ │ ├── BeforeInputEventPlugin.ts │ │ ├── DOMTopLevelEventTypes.ts │ │ ├── FallbackCompositionState.ts │ │ └── before-input-polyfill.ts │ ├── module.ts │ ├── plugins │ │ ├── angular-editor.spec.ts │ │ ├── angular-editor.ts │ │ ├── with-angular.spec.ts │ │ └── with-angular.ts │ ├── public-api.ts │ ├── styles │ │ └── index.scss │ ├── test.ts │ ├── testing │ │ ├── advanced-editable.component.ts │ │ ├── basic-editable.component.ts │ │ ├── create-document.ts │ │ ├── dispatcher-events.ts │ │ ├── editable-with-outlet.component.ts │ │ ├── element-focus.ts │ │ ├── events.ts │ │ ├── image-editable.component.ts │ │ ├── index.ts │ │ ├── leaf.component.ts │ │ ├── module.ts │ │ └── types.ts │ ├── types │ │ ├── clipboard.ts │ │ ├── error.ts │ │ ├── feature.ts │ │ ├── index.ts │ │ └── view.ts │ ├── utils │ │ ├── block-card.ts │ │ ├── clipboard │ │ │ ├── clipboard.ts │ │ │ ├── common.ts │ │ │ ├── data-transfer.ts │ │ │ ├── index.ts │ │ │ └── navigator-clipboard.ts │ │ ├── constants.ts │ │ ├── dom.ts │ │ ├── environment.ts │ │ ├── global-normalize.spec.ts │ │ ├── global-normalize.ts │ │ ├── hotkeys.ts │ │ ├── index.ts │ │ ├── key.ts │ │ ├── lines.ts │ │ ├── range-list.ts │ │ ├── restore-dom.ts │ │ ├── throttle.ts │ │ ├── view.ts │ │ └── weak-maps.ts │ └── view │ │ ├── base.ts │ │ ├── container-item.ts │ │ ├── container.ts │ │ ├── context-change.ts │ │ ├── context.ts │ │ ├── render │ │ ├── leaves-render.ts │ │ ├── list-render.spec.ts │ │ ├── list-render.ts │ │ └── utils.ts │ │ └── types.ts ├── tsconfig.lib.json ├── tsconfig.lib.prod.json └── tsconfig.spec.json ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json └── types └── custom-types.d.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.5.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "worktile/slate-angular" 7 | } 8 | ], 9 | "commit": false, 10 | "linked": [], 11 | "access": "public", 12 | "baseBranch": "master", 13 | "updateInternalDependencies": "patch", 14 | "ignore": [] 15 | } 16 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | browser-tools: circleci/browser-tools@1.4.3 4 | jobs: 5 | build: 6 | working_directory: ~/slate-angular 7 | docker: 8 | - image: cimg/node:20.16.0-browsers 9 | steps: 10 | - browser-tools/install-chrome 11 | - checkout 12 | - run: | 13 | node --version 14 | google-chrome --version 15 | which google-chrome 16 | - restore_cache: 17 | key: slate-angular-{{ .Branch }}-{{ checksum "package-lock.json" }} 18 | - run: npm ci --force 19 | - save_cache: 20 | key: slate-angular-{{ .Branch }}-{{ checksum "package-lock.json" }} 21 | paths: 22 | - 'node_modules' 23 | - run: npm run test -- --no-watch --no-progress --browsers=ChromeHeadlessCI 24 | - run: npm run report-coverage 25 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: 5MUHe1rNtCfuUbqtr7ebinZSzCpYGc5nq 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.css] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 2 15 | insert_final_newline = true 16 | trim_trailing_whitespace = true 17 | 18 | [*.json] 19 | indent_size = 2 20 | 21 | [*.html] 22 | indent_size = 2 23 | 24 | [*.md] 25 | max_line_length = off 26 | trim_trailing_whitespace = false 27 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["projects/**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": ["tsconfig.json", "e2e/tsconfig.json"], 9 | "createDefaultProgram": true 10 | }, 11 | "extends": ["plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates"], 12 | "rules": { 13 | "@angular-eslint/directive-selector": [ 14 | "error", 15 | { 16 | "prefix": "slate", 17 | "style": "camelCase", 18 | "type": "attribute" 19 | } 20 | ], 21 | "@angular-eslint/component-class-suffix": 0, 22 | "@angular-eslint/component-selector": 0, 23 | "@angular-eslint/no-empty-lifecycle-method": 0, 24 | "@angular-eslint/no-host-metadata-property": 0, 25 | "@angular-eslint/no-output-on-prefix": 0, 26 | "@angular-eslint/no-conflicting-lifecycle": 0, 27 | "@angular-eslint/prefer-standalone": "off" 28 | } 29 | }, 30 | { 31 | "files": ["*.html"], 32 | "extends": ["plugin:@angular-eslint/template/recommended"], 33 | "rules": {} 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /dist-demo 6 | /tmp 7 | /out-tsc 8 | # Only exists if Bazel was run 9 | /bazel-out 10 | 11 | # dependencies 12 | /node_modules 13 | 14 | # profiling files 15 | chrome-profiler-events*.json 16 | speed-measure-plugin*.json 17 | 18 | # IDEs and editors 19 | /.idea 20 | .project 21 | .classpath 22 | .c9/ 23 | *.launch 24 | .settings/ 25 | *.sublime-workspace 26 | 27 | # IDE - VSCode 28 | .vscode/* 29 | !.vscode/settings.json 30 | !.vscode/tasks.json 31 | !.vscode/launch.json 32 | !.vscode/extensions.json 33 | .history/* 34 | 35 | # misc 36 | /.angular/cache 37 | /.sass-cache 38 | /connect.lock 39 | /coverage 40 | /libpeerconnection.log 41 | npm-debug.log 42 | yarn-error.log 43 | testem.log 44 | /typings 45 | 46 | # System Files 47 | .DS_Store 48 | Thumbs.db 49 | 50 | # Demo 51 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "arrowParens": "avoid", 5 | "trailingComma": "none", 6 | "printWidth": 140 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:mainline-alpine 2 | RUN rm /etc/nginx/conf.d/* 3 | ADD nginx.conf /etc/nginx/conf.d/ 4 | COPY dist-demo /etc/nginx/html 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 - 2021 Worktile Inc. https://worktile.com 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "packages", 5 | "projects": { 6 | "demo": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "demo", 15 | "prefix": "demo", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:application", 19 | "options": { 20 | "outputPath": { 21 | "base": "dist-demo" 22 | }, 23 | "index": "demo/index.html", 24 | "polyfills": [ 25 | "demo/polyfills.ts" 26 | ], 27 | "tsConfig": "tsconfig.app.json", 28 | "assets": [ 29 | { 30 | "glob": "favicon.ico", 31 | "input": "demo", 32 | "output": "/" 33 | }, 34 | { 35 | "glob": "**/*", 36 | "input": "demo/assets", 37 | "output": "/assets" 38 | } 39 | ], 40 | "styles": ["demo/styles.scss"], 41 | "scripts": [], 42 | "allowedCommonJsDependencies": [ 43 | "lodash", 44 | "debug", 45 | "esrever", 46 | "unified", 47 | "remark-parse", 48 | "remark-slate", 49 | "rxjs/internal/scheduler/asap", 50 | "faker" 51 | ], 52 | "extractLicenses": false, 53 | "sourceMap": true, 54 | "optimization": false, 55 | "namedChunks": true, 56 | "browser": "demo/main.ts" 57 | }, 58 | "configurations": { 59 | "production": { 60 | "fileReplacements": [ 61 | { 62 | "replace": "demo/environments/environment.ts", 63 | "with": "demo/environments/environment.prod.ts" 64 | } 65 | ], 66 | "optimization": true, 67 | "outputHashing": "all", 68 | "sourceMap": false, 69 | "namedChunks": false, 70 | "extractLicenses": true, 71 | "budgets": [ 72 | { 73 | "type": "initial", 74 | "maximumWarning": "2mb", 75 | "maximumError": "5mb" 76 | }, 77 | { 78 | "type": "anyComponentStyle", 79 | "maximumWarning": "6kb", 80 | "maximumError": "10kb" 81 | } 82 | ] 83 | } 84 | }, 85 | "defaultConfiguration": "" 86 | }, 87 | "serve": { 88 | "builder": "@angular-devkit/build-angular:dev-server", 89 | "options": { 90 | "port": 8000, 91 | "buildTarget": "demo:build" 92 | }, 93 | "configurations": { 94 | "production": { 95 | "buildTarget": "demo:build:production" 96 | } 97 | } 98 | }, 99 | "extract-i18n": { 100 | "builder": "@angular-devkit/build-angular:extract-i18n", 101 | "options": { 102 | "buildTarget": "demo:build" 103 | } 104 | }, 105 | "test": { 106 | "builder": "@angular-devkit/build-angular:karma", 107 | "options": { 108 | "main": "demo/test.ts", 109 | "polyfills": "demo/polyfills.ts", 110 | "tsConfig": "tsconfig.spec.json", 111 | "karmaConfig": "karma.conf.js", 112 | "assets": ["demo/favicon.ico", "demo/assets"], 113 | "styles": ["demo/styles.scss"], 114 | "scripts": [] 115 | } 116 | }, 117 | "lint": { 118 | "builder": "@angular-eslint/builder:lint", 119 | "options": { 120 | "lintFilePatterns": ["demo/**/*.ts", "demo/**/*.html"] 121 | } 122 | }, 123 | "e2e": { 124 | "builder": "@angular-devkit/build-angular:protractor", 125 | "options": { 126 | "protractorConfig": "e2e/protractor.conf.js", 127 | "devServerTarget": "demo:serve" 128 | }, 129 | "configurations": { 130 | "production": { 131 | "devServerTarget": "demo:serve:production" 132 | } 133 | } 134 | } 135 | } 136 | }, 137 | "slate-angular": { 138 | "projectType": "library", 139 | "root": "packages", 140 | "sourceRoot": "packages/src", 141 | "prefix": "slate", 142 | "architect": { 143 | "build": { 144 | "builder": "@angular-devkit/build-angular:ng-packagr", 145 | "options": { 146 | "tsConfig": "packages/tsconfig.lib.json", 147 | "project": "packages/ng-package.json" 148 | }, 149 | "configurations": { 150 | "production": { 151 | "tsConfig": "packages/tsconfig.lib.prod.json" 152 | } 153 | } 154 | }, 155 | "test": { 156 | "builder": "@angular-devkit/build-angular:karma", 157 | "options": { 158 | "main": "packages/src/test.ts", 159 | "tsConfig": "packages/tsconfig.spec.json", 160 | "karmaConfig": "packages/karma.conf.js", 161 | "codeCoverage": true, 162 | "codeCoverageExclude": ["packages/src/testing/**/*"] 163 | } 164 | }, 165 | "lint": { 166 | "builder": "@angular-eslint/builder:lint", 167 | "options": { 168 | "lintFilePatterns": ["packages/**/*.ts", "packages/**/*.html"] 169 | } 170 | } 171 | } 172 | } 173 | }, 174 | "cli": { 175 | "analytics": "8db354c6-b560-4f8e-9ad8-56c64f03d2c8", 176 | "schematicCollections": ["@angular-eslint/schematics"] 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'header-max-length': [2, 'always', 200], 5 | 'scope-empty': [2, 'never'], 6 | 'scope-enum': [ 7 | 2, 8 | 'always', 9 | [ 10 | 'demo', 11 | 'deps', 12 | 'core', 13 | 'view', 14 | 'release', 15 | 'plugin', 16 | 'config', 17 | 'template', 18 | 'browser', 19 | 'docs', 20 | 'decorate', 21 | 'pretter', 22 | 'types', 23 | 'block-card', 24 | 'global-normalize', 25 | 'placeholder' 26 | ] 27 | ] 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /custom-types.d.ts: -------------------------------------------------------------------------------- 1 | import { Descendant, BaseEditor } from 'slate'; 2 | import { AngularEditor } from 'slate-angular'; 3 | 4 | export type BlockQuoteElement = { type: 'block-quote'; children: Descendant[] }; 5 | 6 | export type BulletedListElement = { 7 | type: 'bulleted-list'; 8 | children: Descendant[]; 9 | }; 10 | 11 | export type NumberedListElement = { 12 | type: 'numbered-list'; 13 | children: Descendant[]; 14 | }; 15 | 16 | export type CheckListItemElement = { 17 | type: 'check-list-item'; 18 | checked: boolean; 19 | children: Descendant[]; 20 | }; 21 | 22 | export type EditableVoidElement = { 23 | type: 'editable-void'; 24 | children: EmptyText[]; 25 | }; 26 | 27 | export type HeadingOneElement = { type: 'heading-one'; children: Descendant[] }; 28 | export type HeadingTwoElement = { type: 'heading-two'; children: Descendant[] }; 29 | export type HeadingThreeElement = { 30 | type: 'heading-three'; 31 | children: Descendant[]; 32 | }; 33 | export type HeadingFourElement = { 34 | type: 'heading-four'; 35 | children: Descendant[]; 36 | }; 37 | export type HeadingFiveElement = { 38 | type: 'heading-five'; 39 | children: Descendant[]; 40 | }; 41 | export type HeadingSixElement = { type: 'heading-six'; children: Descendant[] }; 42 | 43 | export type ImageElement = { 44 | type: 'image'; 45 | url: string; 46 | children: EmptyText[]; 47 | }; 48 | 49 | export type LinkElement = { type: 'link'; url: string; children: Descendant[] }; 50 | 51 | export type ButtonElement = { type: 'button'; children: Descendant[] }; 52 | 53 | export type ListItemElement = { type: 'list-item'; children: Descendant[] }; 54 | 55 | export type MentionElement = { 56 | type: 'mention'; 57 | character: string; 58 | children: CustomText[]; 59 | }; 60 | 61 | export type ParagraphElement = { type: 'paragraph'; children: Descendant[] }; 62 | 63 | export type TableElement = { type: 'table'; children: TableRowElement[] }; 64 | 65 | export type TableCellElement = { type: 'table-cell'; children: Descendant[] }; 66 | 67 | export type TableRowElement = { 68 | type: 'table-row'; 69 | children: TableCellElement[]; 70 | }; 71 | 72 | export type TitleElement = { type: 'title'; children: Descendant[] }; 73 | 74 | export type VideoElement = { 75 | type: 'video'; 76 | url: string; 77 | children: EmptyText[]; 78 | }; 79 | 80 | type CustomElement = 81 | | BlockQuoteElement 82 | | NumberedListElement 83 | | BulletedListElement 84 | | CheckListItemElement 85 | | EditableVoidElement 86 | | HeadingOneElement 87 | | HeadingTwoElement 88 | | HeadingThreeElement 89 | | HeadingFourElement 90 | | HeadingFiveElement 91 | | HeadingSixElement 92 | | ImageElement 93 | | LinkElement 94 | | ListItemElement 95 | | MentionElement 96 | | ParagraphElement 97 | | TableElement 98 | | TableRowElement 99 | | TableCellElement 100 | | TitleElement 101 | | VideoElement 102 | | ButtonElement; 103 | 104 | export type CustomText = { 105 | placeholder?: string; 106 | bold?: boolean; 107 | italic?: boolean; 108 | code?: boolean; 109 | text: string; 110 | 'code-line'?: boolean; 111 | }; 112 | 113 | export type EmptyText = { 114 | text: string; 115 | }; 116 | 117 | export type CustomEditor = BaseEditor & AngularEditor; 118 | 119 | declare module 'slate' { 120 | interface CustomTypes { 121 | Editor: CustomEditor; 122 | Element: CustomElement; 123 | Text: CustomText | EmptyText; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /demo/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { DemoRichtextComponent } from './richtext/richtext.component'; 4 | import { DemoHugeDocumentComponent } from './huge-document/huge-document.component'; 5 | import { DemoMarkdownShortcutsComponent } from './markdown-shorcuts/markdown-shortcuts.component'; 6 | import { DemoTablesComponent } from './tables/tables.component'; 7 | import { DemoImagesComponent } from './images/images.component'; 8 | import { DemoSearchHighlightingComponent } from './search-highlighting/search-highlighting.component'; 9 | import { DemoMentionsComponent } from './mentions/mentions.component'; 10 | import { DemoReadonlyComponent } from './readonly/readonly.component'; 11 | import { DemoPlaceholderComponent } from './placeholder/placeholder.component'; 12 | import { DemoInlinesComponent } from './inlines/inlines.component'; 13 | import { DemoEditableVoidsComponent } from './editable-voids/editable-voids.component'; 14 | import { DemoEmbedsComponent } from './embeds/embeds.component'; 15 | 16 | const routes: Routes = [ 17 | { 18 | path: 'readonly', 19 | component: DemoReadonlyComponent 20 | }, 21 | { 22 | path: '', 23 | component: DemoRichtextComponent 24 | }, 25 | { 26 | path: 'huge-document', 27 | component: DemoHugeDocumentComponent 28 | }, 29 | { 30 | path: 'markdown-shortcuts', 31 | component: DemoMarkdownShortcutsComponent 32 | }, 33 | { 34 | path: 'tables', 35 | component: DemoTablesComponent 36 | }, 37 | { 38 | path: 'images', 39 | component: DemoImagesComponent 40 | }, 41 | { 42 | path: 'inlines', 43 | component: DemoInlinesComponent 44 | }, 45 | { 46 | path: 'search-highlighting', 47 | component: DemoSearchHighlightingComponent 48 | }, 49 | { 50 | path: 'mentions', 51 | component: DemoMentionsComponent 52 | }, 53 | { 54 | path: 'placeholder', 55 | component: DemoPlaceholderComponent 56 | }, 57 | { 58 | path: 'editable-voids', 59 | component: DemoEditableVoidsComponent 60 | }, 61 | { 62 | path: 'embeds', 63 | component: DemoEmbedsComponent 64 | } 65 | ]; 66 | @NgModule({ 67 | imports: [ 68 | RouterModule.forRoot(routes, { 69 | useHash: false 70 | }) 71 | ], 72 | exports: [RouterModule] 73 | }) 74 | export class AppRoutingModule {} 75 | -------------------------------------------------------------------------------- /demo/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 8 | 12 |
13 |
14 | menu 15 | {{ activeNav?.name }} 16 |
17 |
18 | 32 |
33 |
34 | 35 |
36 | -------------------------------------------------------------------------------- /demo/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async(() => { 7 | TestBed.configureTestingModule({ 8 | imports: [RouterTestingModule], 9 | declarations: [AppComponent] 10 | }).compileComponents(); 11 | })); 12 | 13 | it('should create the app', () => { 14 | const fixture = TestBed.createComponent(AppComponent); 15 | const app = fixture.componentInstance; 16 | expect(app).toBeTruthy(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /demo/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; 2 | @Component({ 3 | selector: 'demo-app-root', 4 | templateUrl: './app.component.html', 5 | providers: [], 6 | standalone: false 7 | }) 8 | export class AppComponent implements OnInit { 9 | menus: Array<{ url: string; name: string }> = [ 10 | { 11 | url: '/readonly', 12 | name: 'Readonly' 13 | }, 14 | { 15 | url: '/', 16 | name: 'RichText' 17 | }, 18 | { 19 | url: '/huge-document', 20 | name: 'Huge Document' 21 | }, 22 | { 23 | url: '/markdown-shortcuts', 24 | name: 'Markdown Shortcuts' 25 | }, 26 | { 27 | url: '/mentions', 28 | name: 'Mentions' 29 | }, 30 | { 31 | url: '/tables', 32 | name: 'Tables' 33 | }, 34 | { 35 | url: '/images', 36 | name: 'Images' 37 | }, 38 | { 39 | url: '/inlines', 40 | name: 'Inlines' 41 | }, 42 | { 43 | url: '/search-highlighting', 44 | name: 'Search Highlighting' 45 | }, 46 | { 47 | url: '/placeholder', 48 | name: 'Placeholder' 49 | }, 50 | { 51 | url: '/editable-voids', 52 | name: 'Editable voids' 53 | }, 54 | { 55 | url: '/embeds', 56 | name: 'Embeds' 57 | } 58 | ]; 59 | 60 | showSideNav: boolean; 61 | 62 | get activeNav() { 63 | return this.menus.filter(item => window.location.href.endsWith(item.url))[0]; 64 | } 65 | 66 | @ViewChild('sideNav', { static: false }) sideNav: ElementRef; 67 | 68 | isSelected(item) { 69 | return window.location.href.endsWith(item.url); 70 | } 71 | 72 | onBreadClick() { 73 | this.showSideNav = !this.showSideNav; 74 | } 75 | 76 | ngOnInit(): void {} 77 | } 78 | -------------------------------------------------------------------------------- /demo/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { AppRoutingModule } from './app-routing.module'; 2 | import { AppComponent } from './app.component'; 3 | import { DemoMarkdownShortcutsComponent } from './markdown-shorcuts/markdown-shortcuts.component'; 4 | import { DemoRichtextComponent } from './richtext/richtext.component'; 5 | import { DemoHugeDocumentComponent } from './huge-document/huge-document.component'; 6 | import { FormsModule } from '@angular/forms'; 7 | import { BrowserModule } from '@angular/platform-browser'; 8 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 9 | import { NgModule } from '@angular/core'; 10 | import { SlateModule } from 'slate-angular'; 11 | import { DemoElementImageComponent } from './components/image/image-component'; 12 | import { DemoTextMarkComponent } from './components/text/text.component'; 13 | import { DemoButtonComponent } from './components/button/button.component'; 14 | import { DemoTablesComponent } from './tables/tables.component'; 15 | import { DemoImagesComponent } from './images/images.component'; 16 | import { DemoSearchHighlightingComponent } from './search-highlighting/search-highlighting.component'; 17 | import { DemoLeafComponent } from './search-highlighting/leaf.component'; 18 | import { DemoMentionsComponent } from './mentions/mentions.component'; 19 | import { DemoReadonlyComponent } from './readonly/readonly.component'; 20 | import { DemoPlaceholderComponent } from './placeholder/placeholder.component'; 21 | import { DemoElementEditableButtonComponent } from './components/editable-button/editable-button.component'; 22 | import { DemoInlinesComponent } from './inlines/inlines.component'; 23 | import { DemoElementLinkComponent } from './components/link/link.component'; 24 | import { DemoEditableVoidsComponent } from './editable-voids/editable-voids.component'; 25 | import { DemoEmbedsComponent } from './embeds/embeds.component'; 26 | 27 | @NgModule({ 28 | declarations: [AppComponent], 29 | imports: [ 30 | BrowserModule, 31 | BrowserAnimationsModule, 32 | AppRoutingModule, 33 | FormsModule, 34 | SlateModule, 35 | DemoButtonComponent, 36 | DemoRichtextComponent, 37 | DemoMarkdownShortcutsComponent, 38 | DemoHugeDocumentComponent, 39 | DemoElementImageComponent, 40 | DemoTextMarkComponent, 41 | DemoTablesComponent, 42 | DemoTablesComponent, 43 | DemoImagesComponent, 44 | DemoSearchHighlightingComponent, 45 | DemoLeafComponent, 46 | DemoMentionsComponent, 47 | DemoReadonlyComponent, 48 | DemoPlaceholderComponent, 49 | DemoElementEditableButtonComponent, 50 | DemoInlinesComponent, 51 | DemoElementLinkComponent, 52 | DemoEditableVoidsComponent, 53 | DemoEmbedsComponent 54 | ], 55 | providers: [], 56 | bootstrap: [AppComponent] 57 | }) 58 | export class AppModule { 59 | constructor() {} 60 | } 61 | -------------------------------------------------------------------------------- /demo/app/components/button/button.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | Directive, 4 | ElementRef, 5 | EventEmitter, 6 | HostBinding, 7 | HostListener, 8 | Input, 9 | OnChanges, 10 | Output, 11 | Renderer2 12 | } from '@angular/core'; 13 | 14 | @Component({ 15 | selector: 'demo-button', 16 | template: '', 17 | host: { 18 | style: 'cursor: pointer' 19 | }, 20 | standalone: true 21 | }) 22 | export class DemoButtonComponent implements OnChanges { 23 | @Input() active = false; 24 | 25 | @Output() onMouseDown: EventEmitter = new EventEmitter(); 26 | 27 | constructor( 28 | private elementRef: ElementRef, 29 | private renderer2: Renderer2 30 | ) {} 31 | 32 | @HostListener('mousedown', ['$event']) 33 | mousedown(event: MouseEvent) { 34 | event.preventDefault(); 35 | this.onMouseDown.emit(event); 36 | } 37 | 38 | ngOnChanges() { 39 | this.renderer2.setStyle(this.elementRef.nativeElement, 'color', this.active ? 'black' : '#ccc'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /demo/app/components/editable-button/editable-button.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, HostListener } from '@angular/core'; 2 | import { ButtonElement } from '../../../../custom-types'; 3 | import { BaseElementComponent } from 'slate-angular'; 4 | import { SlateChildrenOutlet } from '../../../../packages/src/components/children/children-outlet.component'; 5 | 6 | @Component({ 7 | selector: 'span[demo-element-button]', 8 | template: ` 9 | {{ inlineChromiumBugfix }} 10 | 11 | {{ inlineChromiumBugfix }} 12 | `, 13 | host: { 14 | class: 'demo-element-button' 15 | }, 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [SlateChildrenOutlet] 18 | }) 19 | export class DemoElementEditableButtonComponent extends BaseElementComponent { 20 | // Put this at the start and end of an inline component to work around this Chromium bug: 21 | // https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 22 | inlineChromiumBugfix = '$' + String.fromCodePoint(160); 23 | 24 | @HostListener('click', ['$event']) 25 | click(event: MouseEvent) { 26 | event.preventDefault(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo/app/components/editable-void/editable-void.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Name:

4 |
5 | 6 |
7 |

Left or right handed

8 | 12 |
13 | 17 |

Tell us about yourself:

18 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /demo/app/components/editable-void/editable-void.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | .box { 3 | box-shadow: 0 0 0 3px; 4 | padding: 8px; 5 | } 6 | 7 | .input { 8 | margin: 8px 0; 9 | 10 | color: #ccc; 11 | border: 2px solid #ccc; 12 | border-radius: 4px; 13 | 14 | input { 15 | border: none; 16 | outline: none; 17 | background-color: transparent; 18 | } 19 | } 20 | 21 | .unsetWidth { 22 | width: unset; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demo/app/components/editable-void/editable-void.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | import { DemoRichtextComponent } from 'demo/app/richtext/richtext.component'; 4 | import { BaseElementComponent } from 'slate-angular'; 5 | import { EditableVoidElement } from 'custom-types'; 6 | 7 | @Component({ 8 | selector: 'demo-editable-void', 9 | imports: [DemoRichtextComponent], 10 | templateUrl: './editable-void.component.html', 11 | styleUrls: ['./editable-void.component.scss'], 12 | changeDetection: ChangeDetectionStrategy.OnPush 13 | }) 14 | export class DemoElementEditableVoid extends BaseElementComponent { 15 | inputValue: string = ''; 16 | 17 | setInputValue(event: Event) { 18 | this.inputValue = (event.target as HTMLInputElement).value; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /demo/app/components/image/image-component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ImageElement } from '../../../../custom-types'; 3 | import { BaseElementComponent } from 'slate-angular'; 4 | 5 | @Component({ 6 | selector: 'demo-element-image', 7 | template: ` `, 8 | host: { 9 | class: 'demo-element-image' 10 | } 11 | }) 12 | export class DemoElementImageComponent extends BaseElementComponent {} 13 | -------------------------------------------------------------------------------- /demo/app/components/link/link.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, HostBinding, HostListener } from '@angular/core'; 2 | import { LinkElement } from 'custom-types'; 3 | import { BaseElementComponent } from 'slate-angular'; 4 | import { SlateChildrenOutlet } from '../../../../packages/src/components/children/children-outlet.component'; 5 | 6 | @Component({ 7 | selector: 'a[demo-element-link]', 8 | template: ` 9 | {{ inlineChromiumBugfix }} 10 | 11 | {{ inlineChromiumBugfix }} 12 | `, 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | imports: [SlateChildrenOutlet] 15 | }) 16 | export class DemoElementLinkComponent extends BaseElementComponent { 17 | // Put this at the start and end of an inline component to work around this Chromium bug: 18 | // https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 19 | inlineChromiumBugfix = '$' + String.fromCodePoint(160); 20 | 21 | @HostBinding('class.demo-element-link-active') 22 | get active() { 23 | return this.isCollapsed; 24 | } 25 | 26 | @HostBinding('attr.href') 27 | get herf() { 28 | return this.element.url; 29 | } 30 | 31 | @HostListener('click', ['$event']) 32 | click(event: MouseEvent) { 33 | event.preventDefault(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /demo/app/components/text/text.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, Renderer2 } from '@angular/core'; 2 | import { BaseTextComponent } from 'slate-angular'; 3 | 4 | export enum MarkTypes { 5 | bold = 'bold', 6 | italic = 'italic', 7 | underline = 'underlined', 8 | strike = 'strike', 9 | code = 'code-line' 10 | } 11 | 12 | @Component({ 13 | selector: 'span[textMark]', 14 | template: ``, 15 | host: { 16 | 'data-slate-node': 'text' 17 | } 18 | }) 19 | export class DemoTextMarkComponent extends BaseTextComponent { 20 | attributes = []; 21 | 22 | public renderer2 = inject(Renderer2); 23 | 24 | applyTextMark() { 25 | this.attributes.forEach(attr => { 26 | this.renderer2.removeAttribute(this.elementRef.nativeElement, attr); 27 | }); 28 | this.attributes = []; 29 | for (const key in this.text) { 30 | if (Object.prototype.hasOwnProperty.call(this.text, key) && key !== 'text' && !!this.text[key]) { 31 | const attr = `slate-${key}`; 32 | this.renderer2.setAttribute(this.elementRef.nativeElement, attr, 'true'); 33 | this.attributes.push(attr); 34 | } 35 | } 36 | } 37 | 38 | onContextChange() { 39 | super.onContextChange(); 40 | this.applyTextMark(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /demo/app/components/video/video.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 | 12 |
13 | -------------------------------------------------------------------------------- /demo/app/components/video/video.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/2f2fd8396aca8135effa79db10d753bc09853563/demo/app/components/video/video.component.scss -------------------------------------------------------------------------------- /demo/app/components/video/video.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; 3 | import { VideoElement } from 'custom-types'; 4 | import { Element as SlateElement, Transforms } from 'slate'; 5 | import { AngularEditor, BaseElementComponent } from 'slate-angular'; 6 | 7 | @Component({ 8 | selector: 'demo-video', 9 | templateUrl: './video.component.html', 10 | styleUrls: ['./video.component.scss'] 11 | }) 12 | export class DemoElementVideoComponent extends BaseElementComponent { 13 | private sanitizer = inject(DomSanitizer); 14 | 15 | get url(): SafeUrl { 16 | return this.element.url ? this.sanitizer.bypassSecurityTrustResourceUrl(this.element.url + '?title=0&byline=0&portrait=0') : ''; 17 | } 18 | 19 | inputChange(event: Event) { 20 | const newUrl = (event.target as HTMLInputElement).value; 21 | const path = AngularEditor.findPath(this.editor, this.element); 22 | const newProperties: Partial = { 23 | url: newUrl 24 | }; 25 | Transforms.setNodes(this.editor, newProperties, { 26 | at: path 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /demo/app/editable-voids/editable-voids.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | @for (toolbarItem of toolbarItems; track $index) { 4 | {{ toolbarItem.icon }} 7 | } 8 |
9 | 16 | 17 |
18 | -------------------------------------------------------------------------------- /demo/app/editable-voids/editable-voids.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | [data-slate-editor] > * + * { 3 | margin-top: 1em; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /demo/app/editable-voids/editable-voids.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { Descendant, Editor, Element as SlateElement, Text, Transforms, createEditor } from 'slate'; 4 | import { SlateEditable, withAngular } from 'slate-angular'; 5 | import { withHistory } from 'slate-history'; 6 | import { DemoButtonComponent } from '../components/button/button.component'; 7 | 8 | import { EditableVoidElement } from 'custom-types'; 9 | import { DemoElementEditableVoid } from '../components/editable-void/editable-void.component'; 10 | 11 | interface ToolbarItem { 12 | icon: string; 13 | active: () => boolean; 14 | action: (event: Event) => void; 15 | } 16 | 17 | @Component({ 18 | selector: 'demo-editable-voids', 19 | templateUrl: './editable-voids.component.html', 20 | styleUrls: ['./editable-voids.component.scss'], 21 | imports: [SlateEditable, FormsModule, DemoButtonComponent] 22 | }) 23 | export class DemoEditableVoidsComponent { 24 | value = initialValue; 25 | 26 | editor = withEditableVoids(withHistory(withAngular(createEditor()))); 27 | 28 | toolbarItems: Array = [ 29 | { 30 | icon: 'add', 31 | active: () => true, 32 | action: event => { 33 | event.preventDefault(); 34 | const text: Text = { text: '' }; 35 | const voidNode: EditableVoidElement = { 36 | type: 'editable-void', 37 | children: [text] 38 | }; 39 | Transforms.insertNodes(this.editor, voidNode); 40 | } 41 | } 42 | ]; 43 | 44 | renderElement = (element: SlateElement) => { 45 | if (element.type === 'editable-void') { 46 | return DemoElementEditableVoid; 47 | } 48 | return null; 49 | }; 50 | } 51 | 52 | const withEditableVoids = (editor: Editor) => { 53 | const { isVoid } = editor; 54 | 55 | editor.isVoid = element => { 56 | return element.type === 'editable-void' || isVoid(element); 57 | }; 58 | 59 | return editor; 60 | }; 61 | 62 | const initialValue: Descendant[] = [ 63 | { 64 | type: 'paragraph', 65 | children: [ 66 | { 67 | text: 'In addition to nodes that contain editable text, you can insert void nodes, which can also contain editable elements, inputs, or an entire other Slate editor.' 68 | } 69 | ] 70 | }, 71 | { 72 | type: 'editable-void', 73 | children: [{ text: '' }] 74 | }, 75 | { 76 | type: 'paragraph', 77 | children: [ 78 | { 79 | text: '' 80 | } 81 | ] 82 | } 83 | ]; 84 | -------------------------------------------------------------------------------- /demo/app/embeds/embeds.component.html: -------------------------------------------------------------------------------- 1 |
2 | 9 | 10 |
11 | -------------------------------------------------------------------------------- /demo/app/embeds/embeds.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/2f2fd8396aca8135effa79db10d753bc09853563/demo/app/embeds/embeds.component.scss -------------------------------------------------------------------------------- /demo/app/embeds/embeds.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { Descendant, Editor, createEditor, Element as SlateElement } from 'slate'; 4 | import { SlateEditable, withAngular } from 'slate-angular'; 5 | import { DemoElementVideoComponent } from '../components/video/video.component'; 6 | 7 | @Component({ 8 | selector: 'demo-embeds', 9 | templateUrl: './embeds.component.html', 10 | styleUrls: ['./embeds.component.scss'], 11 | imports: [SlateEditable, FormsModule] 12 | }) 13 | export class DemoEmbedsComponent { 14 | value = initialValue; 15 | 16 | editor = withEmbed(withAngular(createEditor())); 17 | 18 | renderElement = (element: SlateElement) => { 19 | if (element.type === 'video') { 20 | return DemoElementVideoComponent; 21 | } 22 | return null; 23 | }; 24 | } 25 | 26 | const withEmbed = (editor: Editor) => { 27 | const { isVoid } = editor; 28 | 29 | editor.isVoid = element => element.type === 'video' || isVoid(element); 30 | 31 | return editor; 32 | }; 33 | 34 | const initialValue: Descendant[] = [ 35 | { 36 | type: 'paragraph', 37 | children: [ 38 | { 39 | text: 'In addition to simple image nodes, you can actually create complex embedded nodes. For example, this one contains an input element that lets you change the video being rendered!' 40 | } 41 | ] 42 | }, 43 | { 44 | type: 'video', 45 | url: 'https://player.vimeo.com/video/26689853', 46 | children: [{ text: '' }] 47 | }, 48 | { 49 | type: 'paragraph', 50 | children: [ 51 | { 52 | text: 'Try it out! This editor is built to handle Vimeo embeds, but you could handle any type.' 53 | } 54 | ] 55 | } 56 | ]; 57 | -------------------------------------------------------------------------------- /demo/app/huge-document/huge-document.component.html: -------------------------------------------------------------------------------- 1 | @if (mode === 'default') { 2 |
3 | 10 | 11 |
12 | } 13 | @if (mode === 'component') { 14 |
15 | @for (item of value; track $index) { 16 | 23 | 24 | } 25 |
26 | } 27 | 28 | @if (context.element.type === 'heading-one') { 29 |

30 | } 31 |
32 | -------------------------------------------------------------------------------- /demo/app/huge-document/huge-document.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild, TemplateRef, AfterViewInit, NgZone } from '@angular/core'; 2 | import { faker } from '@faker-js/faker'; 3 | import { createEditor } from 'slate'; 4 | import { withAngular } from 'slate-angular'; 5 | import { take } from 'rxjs/operators'; 6 | import { SlateElement } from '../../../packages/src/components/element/element.component'; 7 | import { FormsModule } from '@angular/forms'; 8 | import { SlateEditable } from '../../../packages/src/components/editable/editable.component'; 9 | 10 | @Component({ 11 | selector: 'demo-huge-document', 12 | templateUrl: 'huge-document.component.html', 13 | imports: [SlateEditable, FormsModule, SlateElement] 14 | }) 15 | export class DemoHugeDocumentComponent implements OnInit, AfterViewInit { 16 | mode: 'default' | 'component' = 'default'; 17 | 18 | value = buildInitialValue(); 19 | 20 | componentValue = [ 21 | { 22 | type: 'paragraph', 23 | children: [{ text: faker.lorem.paragraph() }] 24 | } 25 | ]; 26 | 27 | editor = withAngular(createEditor()); 28 | 29 | @ViewChild('elementTemplate', { read: TemplateRef, static: true }) 30 | elementTemplate: TemplateRef; 31 | 32 | constructor(private ngZone: NgZone) {} 33 | 34 | ngOnInit() { 35 | console.time(); 36 | } 37 | 38 | ngAfterViewInit(): void { 39 | this.ngZone.onStable.pipe(take(1)).subscribe(() => { 40 | console.timeEnd(); 41 | }); 42 | } 43 | 44 | renderElement() { 45 | return (element: any) => { 46 | if (element.type === 'heading-one') { 47 | return this.elementTemplate; 48 | } 49 | return null; 50 | }; 51 | } 52 | 53 | valueChange(event) {} 54 | } 55 | 56 | export const buildInitialValue = () => { 57 | const HEADINGS = 2000; 58 | const PARAGRAPHS = 7; 59 | const initialValue = []; 60 | 61 | for (let h = 0; h < HEADINGS; h++) { 62 | initialValue.push({ 63 | type: 'heading-one', 64 | children: [{ text: faker.lorem.sentence() }] 65 | }); 66 | 67 | for (let p = 0; p < PARAGRAPHS; p++) { 68 | initialValue.push({ 69 | type: 'paragraph', 70 | children: [{ text: faker.lorem.paragraph() }] 71 | }); 72 | } 73 | } 74 | return initialValue; 75 | }; 76 | -------------------------------------------------------------------------------- /demo/app/images/images.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | image 5 | 6 |
7 | 14 | 15 |
16 | -------------------------------------------------------------------------------- /demo/app/images/images.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { withAngular } from 'slate-angular'; 3 | import { createEditor, Transforms, Editor, Element } from 'slate'; 4 | import { DemoElementImageComponent } from '../components/image/image-component'; 5 | import { ImageElement } from '../../../custom-types'; 6 | import { FormsModule } from '@angular/forms'; 7 | import { SlateEditable } from '../../../packages/src/components/editable/editable.component'; 8 | import { DemoButtonComponent } from '../components/button/button.component'; 9 | 10 | @Component({ 11 | selector: 'demo-images', 12 | templateUrl: 'images.component.html', 13 | imports: [DemoButtonComponent, SlateEditable, FormsModule] 14 | }) 15 | export class DemoImagesComponent implements OnInit { 16 | value = initialValue; 17 | 18 | editor = withImage(withAngular(createEditor())); 19 | 20 | constructor() {} 21 | 22 | renderElement() { 23 | return (element: Element) => { 24 | if (element.type === 'image') { 25 | return DemoElementImageComponent; 26 | } 27 | return null; 28 | }; 29 | } 30 | 31 | ngOnInit() {} 32 | 33 | isImgUrl(imgUrl: string) { 34 | return new Promise((resolve, reject) => { 35 | const imgObj = new Image(); 36 | imgObj.src = imgUrl; 37 | imgObj.onload = () => { 38 | resolve(true); 39 | }; 40 | imgObj.onerror = () => { 41 | reject(false); 42 | }; 43 | }).catch(error => {}); 44 | } 45 | 46 | createImageNode(imgUrl: string) { 47 | const imageNode: ImageElement = { 48 | type: 'image', 49 | url: imgUrl, 50 | children: [ 51 | { 52 | text: '' 53 | } 54 | ] 55 | }; 56 | Transforms.insertNodes(this.editor, imageNode); 57 | } 58 | 59 | addImages() { 60 | const imgUrl = window.prompt('Enter the URL of the image:'); 61 | if (imgUrl) { 62 | this.isImgUrl(imgUrl).then(value => { 63 | if (value) { 64 | this.createImageNode(imgUrl); 65 | } else { 66 | window.alert('URL is not an image'); 67 | } 68 | }); 69 | } 70 | } 71 | 72 | valueChange(event) {} 73 | } 74 | const initialValue = [ 75 | { 76 | type: 'paragraph', 77 | children: [ 78 | { 79 | text: 'In addition to nodes that contain editable text, you can also create other types of nodes, like images or videos.' 80 | } 81 | ], 82 | key: 'HdSTK' 83 | }, 84 | { 85 | type: 'image', 86 | url: 'https://github.com/images/modules/search/light2x.png', 87 | children: [ 88 | { 89 | text: '' 90 | } 91 | ], 92 | key: 'EwcCn', 93 | voids: true 94 | }, 95 | { 96 | type: 'paragraph', 97 | children: [ 98 | { 99 | text: 'This example shows images in action. It features two ways to add images. You can either add an image via the toolbar icon above, or if you want in on a little secret, copy an image URL to your keyboard and paste it anywhere in the editor!' 100 | } 101 | ], 102 | key: 'ecJaY' 103 | }, 104 | { 105 | type: 'paragraph', 106 | children: [ 107 | { 108 | text: '' 109 | } 110 | ], 111 | key: 'zRTHT' 112 | } 113 | ]; 114 | 115 | const withImage = (editor: Editor) => { 116 | const { isVoid } = editor; 117 | 118 | editor.isVoid = element => { 119 | return element.type === 'image' || isVoid(element); 120 | }; 121 | 122 | editor.isBlockCard = (element: Element) => { 123 | return element.type === 'image' || isVoid(element); 124 | }; 125 | 126 | return editor; 127 | }; 128 | -------------------------------------------------------------------------------- /demo/app/inlines/inlines.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | @for (toolbarItem of toolbarItems; track $index) { 4 | {{ toolbarItem.icon }} 7 | } 8 |
9 | 17 | 18 |
19 | -------------------------------------------------------------------------------- /demo/app/markdown-shorcuts/markdown-shortcuts.component.html: -------------------------------------------------------------------------------- 1 |
2 | 9 | 10 |
11 |
12 | 13 |
  • 14 |
    15 | 16 |
      17 |
      18 | 19 |

      20 |
      21 | 22 |

      23 |
      24 | 25 |

      26 |
      27 | 28 |

      29 |
      30 | 31 |
      32 |
      33 | 34 |
      35 |
      36 |
      37 |
      38 | -------------------------------------------------------------------------------- /demo/app/mentions/mentions.component.html: -------------------------------------------------------------------------------- 1 |
      2 | 10 | 11 | 12 | 13 | @{{ context.element.character }} 14 | 15 | 16 |
      17 | @for (item of suggestions; track $index; let i = $index) { 18 |
      19 | {{ item }} 20 |
      21 | } 22 |
      23 |
      24 | -------------------------------------------------------------------------------- /demo/app/mentions/mentions.component.scss: -------------------------------------------------------------------------------- 1 | .demo-mention-view { 2 | background: #eee; 3 | padding: 0px 8px; 4 | border-radius: 12px; 5 | color: #666; 6 | white-space: nowrap; 7 | display: inline-block; 8 | margin: 0 1px; 9 | line-height: 22px; 10 | &.focus { 11 | box-shadow: 0 0 0 2px #b4d5ff; 12 | } 13 | cursor: pointer; 14 | } 15 | 16 | .demo-mention-suggestion-list { 17 | position: fixed; 18 | left: -999px; 19 | top: -999px; 20 | width: 150px; 21 | max-height: 300px; 22 | overflow-y: auto; 23 | z-index: 1; 24 | background-color: white; 25 | div { 26 | padding: 0; 27 | margin: 0; 28 | line-height: 35px; 29 | padding-left: 15px; 30 | cursor: pointer; 31 | &.active { 32 | background-color: #b4d5ff; 33 | border-radius: 3px; 34 | } 35 | } 36 | padding: 3px; 37 | border-radius: 4px; 38 | box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2); 39 | } 40 | -------------------------------------------------------------------------------- /demo/app/placeholder/placeholder.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { createEditor, Descendant, Editor, Node } from 'slate'; 3 | import { SlatePlaceholder, withAngular } from 'slate-angular'; 4 | import { FormsModule } from '@angular/forms'; 5 | import { SlateEditable } from '../../../packages/src/components/editable/editable.component'; 6 | 7 | @Component({ 8 | selector: 'demo-placeholder', 9 | template: ` 10 |
      11 | 17 |
      18 |
      19 | 25 |
      26 | `, 27 | imports: [SlateEditable, FormsModule] 28 | }) 29 | export class DemoPlaceholderComponent { 30 | constructor() {} 31 | 32 | value = initialValue; 33 | 34 | otherValue = [ 35 | { 36 | type: 'paragraph', 37 | children: [ 38 | { 39 | text: 'Press Enter to make new paragraph and will show placeholder' 40 | } 41 | ] 42 | } 43 | ]; 44 | 45 | placeholderDecorate: (editor: Editor) => SlatePlaceholder[] = editor => { 46 | const cursorAnchor = editor.selection?.anchor; 47 | if (cursorAnchor) { 48 | const parent = Node.parent(editor, cursorAnchor.path); 49 | if (parent.children.length === 1 && Array.from(Node.texts(parent)).length === 1 && Node.string(parent) === '') { 50 | const start = Editor.start(editor, cursorAnchor); 51 | return [ 52 | { 53 | placeholder: 'advance placeholder use with placeholderDecoration', 54 | anchor: start, 55 | focus: start 56 | } 57 | ]; 58 | } else { 59 | return []; 60 | } 61 | } 62 | return []; 63 | }; 64 | 65 | editor = withAngular(createEditor()); 66 | 67 | editorWithCustomDecoration = withAngular(createEditor()); 68 | } 69 | 70 | const initialValue: Descendant[] = [ 71 | { 72 | type: 'paragraph', 73 | children: [ 74 | { 75 | text: '' 76 | } 77 | ] 78 | } 79 | ]; 80 | -------------------------------------------------------------------------------- /demo/app/plugins/block-cards.plugin.ts: -------------------------------------------------------------------------------- 1 | import { Editor, Path, Transforms, Location } from 'slate'; 2 | import { AngularEditor, hasBlockCard, isCardLeft } from 'slate-angular'; 3 | import { HistoryEditor } from 'slate-history'; 4 | 5 | export const withBlockCard = editor => { 6 | const { insertBreak, deleteBackward, deleteForward, insertText } = editor; 7 | 8 | editor.insertBreak = () => { 9 | const domSelection = window.getSelection(); 10 | const anchorNode = domSelection.anchorNode; 11 | if (domSelection && domSelection.isCollapsed && hasBlockCard(domSelection)) { 12 | const isLeftCursor = isCardLeft(anchorNode); 13 | const cardEntry = AngularEditor.toSlateCardEntry(editor, anchorNode); 14 | const cursorRootPath = cardEntry[1]; 15 | insertParagraph(editor, isLeftCursor ? cursorRootPath : Path.next(cursorRootPath)); 16 | if (!isLeftCursor) { 17 | Transforms.select(editor, Path.next(cursorRootPath)); 18 | } 19 | return; 20 | } 21 | insertBreak(); 22 | }; 23 | 24 | editor.deleteBackward = unit => { 25 | const domSelection = window.getSelection(); 26 | const anchorNode = domSelection.anchorNode; 27 | if (domSelection && domSelection.isCollapsed && hasBlockCard(domSelection)) { 28 | const isLeftCursor = isCardLeft(anchorNode); 29 | const cardEntry = AngularEditor.toSlateCardEntry(editor, anchorNode); 30 | const cursorRootPath = cardEntry[1]; 31 | if (isLeftCursor) { 32 | const previousPath = Path.previous(cursorRootPath); 33 | HistoryEditor.withoutMerging(editor, () => { 34 | Transforms.select(editor, Editor.end(editor, previousPath)); 35 | }); 36 | return; 37 | } else { 38 | insertParagraph(editor, cursorRootPath); 39 | Transforms.select(editor, cursorRootPath); 40 | Transforms.removeNodes(editor, { 41 | at: Path.next(cursorRootPath) 42 | }); 43 | return; 44 | } 45 | } 46 | deleteBackward(unit); 47 | }; 48 | 49 | editor.deleteForward = unit => { 50 | const domSelection = window.getSelection(); 51 | const anchorNode = domSelection.anchorNode; 52 | if (domSelection && domSelection.isCollapsed && hasBlockCard(domSelection)) { 53 | const isLeftCursor = isCardLeft(anchorNode); 54 | const cardEntry = AngularEditor.toSlateCardEntry(editor, anchorNode); 55 | const cursorRootPath = cardEntry[1]; 56 | if (isLeftCursor) { 57 | insertParagraph(editor, cursorRootPath); 58 | Transforms.select(editor, cursorRootPath); 59 | Transforms.removeNodes(editor, { 60 | at: Path.next(cursorRootPath) 61 | }); 62 | return; 63 | } else { 64 | const nextPath = Path.next(cursorRootPath); 65 | HistoryEditor.withoutMerging(editor, () => { 66 | Transforms.select(editor, Editor.start(editor, nextPath)); 67 | }); 68 | return; 69 | } 70 | } 71 | 72 | deleteForward(unit); 73 | }; 74 | 75 | editor.insertText = (text: string) => { 76 | const domSelection = window.getSelection(); 77 | const anchorNode = domSelection?.anchorNode; 78 | if (domSelection && domSelection.isCollapsed && hasBlockCard(domSelection)) { 79 | const isLeftCursor = isCardLeft(anchorNode); 80 | const cardEntry = AngularEditor.toSlateCardEntry(editor, anchorNode); 81 | const cursorRootPath = cardEntry[1]; 82 | if (isLeftCursor) { 83 | insertParagraph(editor, cursorRootPath); 84 | Transforms.select(editor, cursorRootPath); 85 | } else { 86 | const nextPath = Path.next(cursorRootPath); 87 | insertParagraph(editor, nextPath); 88 | Transforms.select(editor, nextPath); 89 | } 90 | } 91 | insertText(text); 92 | }; 93 | 94 | return editor; 95 | }; 96 | 97 | const insertParagraph = (editor: Editor, at: Location) => { 98 | Transforms.insertNodes( 99 | editor, 100 | { 101 | type: 'paragraph', 102 | children: [ 103 | { 104 | text: '' 105 | } 106 | ] 107 | }, 108 | { at } 109 | ); 110 | }; 111 | -------------------------------------------------------------------------------- /demo/app/readonly/readonly.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { createEditor, Descendant } from 'slate'; 3 | import { withAngular } from 'slate-angular'; 4 | import { FormsModule } from '@angular/forms'; 5 | import { SlateEditable } from '../../../packages/src/components/editable/editable.component'; 6 | 7 | @Component({ 8 | selector: 'demo-readonly', 9 | template: ` 10 |
      11 | 12 |
      13 | `, 14 | imports: [SlateEditable, FormsModule] 15 | }) 16 | export class DemoReadonlyComponent { 17 | constructor() {} 18 | 19 | value = initialValue; 20 | 21 | editor = withAngular(createEditor()); 22 | } 23 | 24 | const initialValue: Descendant[] = [ 25 | { 26 | type: 'paragraph', 27 | children: [ 28 | { 29 | text: 'This example shows what happens when the Editor is set to readOnly, it is not editable' 30 | } 31 | ] 32 | } 33 | ]; 34 | -------------------------------------------------------------------------------- /demo/app/richtext/richtext.component.html: -------------------------------------------------------------------------------- 1 |
      2 |
      3 | @for (toolbarItem of toolbarItems; track $index) { 4 | {{ toolbarItem.icon }} 7 | } 8 |
      9 | 19 | 20 |

      21 |
      22 | 23 |

      24 |
      25 | 26 |

      27 |
      28 | 29 |
      30 |
      31 | 32 |
        33 |
        34 | 35 |
          36 |
          37 | 38 |
        1. 39 |
          40 |
          41 |
          42 | -------------------------------------------------------------------------------- /demo/app/search-highlighting/leaf.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, Renderer2 } from '@angular/core'; 2 | import { BaseLeafComponent } from 'slate-angular'; 3 | import { SlateString } from '../../../packages/src/components/string/string.component'; 4 | 5 | @Component({ 6 | selector: 'span[demoLeaf]', 7 | template: ` `, 8 | imports: [SlateString] 9 | }) 10 | export class DemoLeafComponent extends BaseLeafComponent { 11 | private renderer = inject(Renderer2); 12 | 13 | onContextChange() { 14 | super.onContextChange(); 15 | this.changeStyle(); 16 | } 17 | 18 | changeStyle() { 19 | const backgroundColor = this.leaf['highlight'] ? '#ffeeba' : null; 20 | this.renderer.setStyle(this.nativeElement, 'backgroundColor', backgroundColor); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/app/search-highlighting/search-highlighting.component.html: -------------------------------------------------------------------------------- 1 |
          2 |
          3 |
          4 | search 5 | 6 |
          7 |
          8 | 16 | 17 |
          18 | -------------------------------------------------------------------------------- /demo/app/search-highlighting/search-highlighting.component.scss: -------------------------------------------------------------------------------- 1 | .search-highlighting-toolbar { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | top: 0.5em; 6 | left: 0.5em; 7 | color: #ccc; 8 | border: 2px solid #ccc; 9 | border-radius: 4px; 10 | 11 | input { 12 | border: none; 13 | outline: none; 14 | background-color: transparent; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /demo/app/search-highlighting/search-highlighting.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; 2 | import { createEditor, NodeEntry, Range, Text } from 'slate'; 3 | import { withAngular } from 'slate-angular'; 4 | import { DemoTextMarkComponent, MarkTypes } from '../components/text/text.component'; 5 | import { DemoLeafComponent } from './leaf.component'; 6 | import { SlateEditable } from '../../../packages/src/components/editable/editable.component'; 7 | import { FormsModule } from '@angular/forms'; 8 | 9 | @Component({ 10 | selector: 'demo-search-highlight', 11 | templateUrl: './search-highlighting.component.html', 12 | styleUrls: ['./search-highlighting.component.scss'], 13 | imports: [FormsModule, SlateEditable] 14 | }) 15 | export class DemoSearchHighlightingComponent implements OnInit { 16 | keywords = ''; 17 | 18 | value = initialValue; 19 | 20 | editor = withAngular(createEditor()); 21 | 22 | decorate: (nodeEntry: NodeEntry) => Range[]; 23 | 24 | constructor(private cdr: ChangeDetectorRef) {} 25 | 26 | ngOnInit(): void { 27 | this.generateDecorate(); 28 | } 29 | 30 | keywordsChange(event) { 31 | this.generateDecorate(); 32 | this.cdr.markForCheck(); 33 | } 34 | 35 | generateDecorate() { 36 | this.decorate = ([node, path]) => { 37 | const ranges = []; 38 | 39 | if (this.keywords && Text.isText(node)) { 40 | const { text } = node; 41 | const parts = text.split(this.keywords); 42 | let offset = 0; 43 | 44 | parts.forEach((part, i) => { 45 | if (i !== 0) { 46 | ranges.push({ 47 | anchor: { 48 | path, 49 | offset: offset - this.keywords.length 50 | }, 51 | focus: { path, offset }, 52 | highlight: true 53 | }); 54 | } 55 | 56 | offset = offset + part.length + this.keywords.length; 57 | }); 58 | } 59 | 60 | return ranges; 61 | }; 62 | } 63 | 64 | renderText = (text: Text) => { 65 | if (text[MarkTypes.bold] || text[MarkTypes.italic] || text[MarkTypes.code] || text[MarkTypes.underline]) { 66 | return DemoTextMarkComponent; 67 | } 68 | }; 69 | 70 | renderLeaf = (text: Text) => { 71 | if (text['highlight']) { 72 | return DemoLeafComponent; 73 | } 74 | return null; 75 | }; 76 | } 77 | 78 | const initialValue = [ 79 | { 80 | children: [ 81 | { 82 | text: 'This is editable text that you can search. As you search, it looks for matching strings of text, and adds ' 83 | }, 84 | { text: 'decorations', bold: true }, 85 | { text: ' to them in realtime.' } 86 | ] 87 | }, 88 | { 89 | children: [ 90 | { 91 | text: 'Try it out for yourself by typing in the search box above!' 92 | } 93 | ] 94 | } 95 | ]; 96 | -------------------------------------------------------------------------------- /demo/app/tables/tables.component.html: -------------------------------------------------------------------------------- 1 |
          2 | 11 | 12 |
          13 |
          14 | 15 | 16 | 17 | 18 | 19 | 20 |
          21 |
          22 | -------------------------------------------------------------------------------- /demo/assets/materialicons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/2f2fd8396aca8135effa79db10d753bc09853563/demo/assets/materialicons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2 -------------------------------------------------------------------------------- /demo/assets/materialicons/icon.css: -------------------------------------------------------------------------------- 1 | /* fallback */ 2 | @font-face { 3 | font-family: 'Material Icons'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url(/assets/materialicons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2) format('woff2'); 7 | } 8 | 9 | .material-icons { 10 | font-family: 'Material Icons'; 11 | font-weight: normal; 12 | font-style: normal; 13 | font-size: 24px; 14 | line-height: 1; 15 | letter-spacing: normal; 16 | text-transform: none; 17 | display: inline-block; 18 | white-space: nowrap; 19 | word-wrap: normal; 20 | direction: ltr; 21 | -webkit-font-feature-settings: 'liga'; 22 | -webkit-font-smoothing: antialiased; 23 | } 24 | -------------------------------------------------------------------------------- /demo/assets/photo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/2f2fd8396aca8135effa79db10d753bc09853563/demo/assets/photo.jpeg -------------------------------------------------------------------------------- /demo/assets/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 17 | 18 | 26 | 29 | 34 | 36 | 40 | 48 | 52 | 53 | 62 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /demo/editor-typo.scss: -------------------------------------------------------------------------------- 1 | .slate-editable-container { 2 | [slate-underlined][slate-strike] { 3 | text-decoration: underline line-through; 4 | } 5 | [slate-strike] { 6 | text-decoration: line-through; 7 | } 8 | [slate-underlined] { 9 | text-decoration: underline; 10 | } 11 | [slate-italic] { 12 | font-style: italic; 13 | } 14 | [slate-bold] { 15 | font-weight: bold; 16 | } 17 | [slate-code-line] { 18 | margin: 0 4px; 19 | padding: 2px 3px; 20 | border: 1px solid rgba($color: #000000, $alpha: 0.08); 21 | border-radius: 2px; 22 | background-color: rgba($color: #000000, $alpha: 0.06); 23 | } 24 | blockquote { 25 | margin: 0; 26 | margin-left: 0; 27 | margin-right: 0; 28 | color: #888; 29 | padding-left: 10px !important; 30 | border-left: 4px solid #eee; 31 | } 32 | h1, 33 | h2, 34 | h3 { 35 | margin: 0px; 36 | } 37 | & > [data-slate-node='element'], 38 | & > slate-block-card { 39 | margin-bottom: 12px; 40 | } 41 | .demo-element-button { 42 | margin: 0 0.1em; 43 | background-color: #efefef; 44 | padding: 2px 6px; 45 | border: 1px solid #767676; 46 | border-radius: 2px; 47 | font-size: 0.9em; 48 | } 49 | .demo-element-link-active { 50 | box-shadow: 0 0 0 3px #ddd; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /demo/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/2f2fd8396aca8135effa79db10d753bc09853563/demo/favicon.ico -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Slate Angular Examples - Angular view layer for Slate 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/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() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /demo/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 | import 'core-js/features/global-this'; 50 | 51 | window['global'] = window as any; 52 | /*************************************************************************************************** 53 | * APPLICATION IMPORTS 54 | */ 55 | -------------------------------------------------------------------------------- /demo/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import './app/app.component.scss'; 3 | @import './editor-typo.scss'; 4 | @import '../packages/src/styles/index.scss'; 5 | @import './assets/materialicons/icon.css'; 6 | 7 | html, 8 | input, 9 | textarea { 10 | font-family: 'Roboto', sans-serif; 11 | line-height: 1.4; 12 | background: #fafafa; 13 | } 14 | 15 | body { 16 | margin: 0; 17 | } 18 | -------------------------------------------------------------------------------- /demo/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 { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 6 | 7 | // First, initialize the Angular testing environment. 8 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 9 | teardown: { destroyAfterEach: false } 10 | }); 11 | -------------------------------------------------------------------------------- /demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "exclude": ["test.ts", "**/*.spec.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /demo/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["test.ts", "polyfills.ts"], 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /docs/compatible.md: -------------------------------------------------------------------------------- 1 | ## Safari 2 | 3 | IME input handle by beforeinput 4 | 5 | ## Chrome 6 | 7 | IME input handle by compositionend 8 | 9 | ## Edge 10 | 11 | IME input handle by compositionend 12 | 13 | ## Firefox 14 | 15 | IME input handle by compositionend 16 | 17 | ## QQ 18 | 19 | IME input handle by compositionend 20 | -------------------------------------------------------------------------------- /docs/images/banner.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/2f2fd8396aca8135effa79db10d753bc09853563/docs/images/banner.jpeg -------------------------------------------------------------------------------- /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: ['./demo/**/*.e2e-spec.ts'], 13 | capabilities: { 14 | browserName: 'chrome' 15 | }, 16 | directConnect: true, 17 | baseUrl: 'http://localhost:4200/', 18 | framework: 'jasmine', 19 | jasmineNodeOpts: { 20 | showColors: true, 21 | defaultTimeoutInterval: 30000, 22 | print: function () {} 23 | }, 24 | onPrepare() { 25 | require('ts-node').register({ 26 | project: require('path').join(__dirname, './tsconfig.json') 27 | }); 28 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /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('test'); 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( 20 | jasmine.objectContaining({ 21 | level: logging.Level.SEVERE 22 | } as logging.Entry) 23 | ); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo(): Promise { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText(): Promise { 9 | return element(by.css('demo-app-root .content span')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es2018", 7 | "types": ["jasmine", "jasminewd2", "node"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /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 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | jasmineHtmlReporter: { 19 | suppressAll: true // removes the duplicated traces 20 | }, 21 | coverageReporter: { 22 | dir: 'coverage/slate-angular', 23 | subdir: '.', 24 | reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'lcovonly' }] 25 | }, 26 | angularCli: { 27 | environment: 'dev' 28 | }, 29 | files: [], 30 | reporters: ['progress', 'kjhtml'], 31 | port: 9876, 32 | colors: true, 33 | logLevel: config.LOG_INFO, 34 | autoWatch: true, 35 | browsers: ['Chrome', 'ChromeHeadless', 'ChromeHeadlessCI'], 36 | singleRun: false, 37 | customLaunchers: { 38 | ChromeHeadlessCI: { 39 | base: 'ChromeHeadless', 40 | flags: ['--no-sandbox'] 41 | } 42 | }, 43 | restartOnFileChange: true 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8080; 3 | 4 | location / { 5 | 6 | if ($request_filename ~ .*\.(htm|html)$) { 7 | add_header Cache-Control no-cache; 8 | } 9 | try_files $uri $uri/ /index.html; 10 | port_in_redirect off; 11 | proxy_redirect off; 12 | proxy_set_header Host $host; 13 | proxy_set_header X-Real-IP $remote_addr; 14 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 15 | proxy_set_header X-Forwarded-Proto http; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slate-angular", 3 | "workspaces": [ 4 | "packages" 5 | ], 6 | "scripts": { 7 | "ng": "ng", 8 | "start": "ng serve demo", 9 | "build": "ng build slate-angular --configuration production && cpx \"./packages/src/**/*.scss\" ./dist/", 10 | "build:demo": "ng build demo", 11 | "pub": "npm run build && cd dist && npm publish --access public", 12 | "pub-next": "npm run build && cd dist && npm publish --tag next --access public", 13 | "patch": "cd packages && npm version patch", 14 | "minor": "cd packages && npm version minor", 15 | "major": "cd packages && npm version major", 16 | "release": "standard-version", 17 | "test": "ng test slate-angular", 18 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 19 | "lint": "ng lint", 20 | "e2e": "ng e2e", 21 | "format": "prettier --check --write \"**/*\"" 22 | }, 23 | "husky": { 24 | "hooks": { 25 | "hooks": { 26 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 27 | "pre-commit": "lint-staged" 28 | } 29 | } 30 | }, 31 | "private": true, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/worktile/slate-angular" 35 | }, 36 | "dependencies": { 37 | "@angular/animations": "~19.2.2", 38 | "@angular/common": "~19.2.2", 39 | "@angular/compiler": "^19.2.2", 40 | "@angular/core": "~19.2.2", 41 | "@angular/forms": "~19.2.2", 42 | "@angular/platform-browser": "~19.2.2", 43 | "@angular/platform-browser-dynamic": "~19.2.2", 44 | "@angular/router": "^19.2.2", 45 | "core-js": "3.35.0", 46 | "direction": "^2.0.1", 47 | "is-hotkey": "^0.2.0", 48 | "rxjs": "~7.8.1", 49 | "scroll-into-view-if-needed": "^3.1.0", 50 | "slate": "^0.103.0", 51 | "slate-history": "^0.100.0", 52 | "tslib": "^2.6.2", 53 | "zone.js": "~0.15.0" 54 | }, 55 | "devDependencies": { 56 | "@angular-devkit/build-angular": "^19.2.3", 57 | "@angular-devkit/core": "^19.2.3", 58 | "@angular-eslint/builder": "19.2.1", 59 | "@angular-eslint/eslint-plugin": "19.2.1", 60 | "@angular-eslint/eslint-plugin-template": "19.2.1", 61 | "@angular-eslint/schematics": "19.2.1", 62 | "@angular-eslint/template-parser": "19.2.1", 63 | "@angular/cli": "^19.2.3", 64 | "@angular/compiler-cli": "^19.2.2", 65 | "@angular/language-service": "^19.2.2", 66 | "@changesets/changelog-github": "^0.4.8", 67 | "@changesets/cli": "^2.26.0", 68 | "@commitlint/cli": "^19.8.0", 69 | "@commitlint/config-conventional": "^19.8.0", 70 | "@faker-js/faker": "^8.3.1", 71 | "@types/codemirror": "5.60.15", 72 | "@types/is-hotkey": "^0.1.10", 73 | "@types/is-url": "^1.2.32", 74 | "@types/jasmine": "~5.1.4", 75 | "@types/jasminewd2": "~2.0.13", 76 | "@types/node": "^18.13.0", 77 | "@typescript-eslint/eslint-plugin": "^6.10.0", 78 | "@typescript-eslint/parser": "^6.10.0", 79 | "coveralls": "^3.1.1", 80 | "cpx": "^1.5.0", 81 | "eslint": "^8.53.0", 82 | "eslint-config-prettier": "^9.1.0", 83 | "eslint-plugin-prettier": "^5.1.3", 84 | "husky": "^8.0.3", 85 | "is-url": "^1.2.4", 86 | "jasmine": "~5.1.0", 87 | "jasmine-core": "~5.1.1", 88 | "karma": "^6.4.2", 89 | "karma-chrome-launcher": "~3.2.0", 90 | "karma-coverage": "~2.2.1", 91 | "karma-jasmine": "~5.1.0", 92 | "karma-jasmine-html-reporter": "~2.1.0", 93 | "lint-staged": "^15.2.0", 94 | "ng-packagr": "^19.2.0", 95 | "prettier": "^3.1.1", 96 | "pretty-quick": "3.1.3", 97 | "standard-version": "^9.5.0", 98 | "ts-node": "~10.9.2", 99 | "typescript": "~5.5.4" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /packages/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/2f2fd8396aca8135effa79db10d753bc09853563/packages/README.md -------------------------------------------------------------------------------- /packages/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 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | jasmineHtmlReporter: { 19 | suppressAll: true // removes the duplicated traces 20 | }, 21 | coverageReporter: { 22 | dir: require('path').join(__dirname, '../coverage/slate-angular'), 23 | subdir: '.', 24 | reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'lcovonly', subdir: '..', file: 'lcov.info' }] 25 | }, 26 | files: [], 27 | reporters: ['progress', 'kjhtml'], 28 | port: 9876, 29 | colors: true, 30 | logLevel: config.LOG_INFO, 31 | autoWatch: true, 32 | browsers: ['Chrome'], 33 | singleRun: false, 34 | customLaunchers: { 35 | ChromeHeadlessCI: { 36 | base: 'ChromeHeadless', 37 | flags: ['--no-sandbox'] 38 | } 39 | }, 40 | restartOnFileChange: true 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../dist", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | }, 7 | "allowedNonPeerDependencies": ["slate-history", "debug", "direction", "is-hotkey", "slate", "scroll-into-view-if-needed"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slate-angular", 3 | "version": "19.0.0", 4 | "description": "Angular view layer for Slate", 5 | "author": "pubuzhixing ", 6 | "homepage": "https://github.com/worktile/slate-angular#readme", 7 | "license": "MIT", 8 | "peerDependencies": { 9 | "slate": ">=0.101.0 <=0.104.0", 10 | "slate-history": "^0.100.0", 11 | "debug": "^4.1.1", 12 | "direction": "^2.0.1", 13 | "is-hotkey": "^0.2.0", 14 | "scroll-into-view-if-needed": "^2.2.20" 15 | }, 16 | "dependencies": { 17 | "slate": ">=0.101.0 <=0.104.0", 18 | "slate-history": "^0.100.0", 19 | "direction": "^2.0.1", 20 | "is-hotkey": "^0.2.0", 21 | "scroll-into-view-if-needed": "^3.1.0", 22 | "tslib": "^2.6.2" 23 | }, 24 | "exports": { 25 | ".": { 26 | "sass": "./styles/index.scss" 27 | }, 28 | "./styles": { 29 | "sass": "./styles/index.scss" 30 | }, 31 | "./styles/index.scss": { 32 | "sass": "./styles/index.scss" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/src/components/block-card/block-card.component.html: -------------------------------------------------------------------------------- 1 | {{ '\uFEFF' }} 2 |
          3 | {{ '\uFEFF' }} 4 | -------------------------------------------------------------------------------- /packages/src/components/block-card/block-card.component.scss: -------------------------------------------------------------------------------- 1 | .slate-block-card { 2 | display: block; 3 | position: relative; 4 | .card-left, 5 | .card-right { 6 | bottom: 0px; 7 | position: absolute; 8 | width: 2px; 9 | overflow: hidden; 10 | user-select: text; 11 | } 12 | .card-left { 13 | left: -2px; 14 | text-align: left; 15 | } 16 | .card-right { 17 | right: -2px; 18 | text-align: right; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/src/components/block-card/block-card.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { ImageEditableComponent, configureBasicEditableTestingModule } from '../../testing'; 4 | 5 | describe('Block Card Component', () => { 6 | let component: ImageEditableComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(fakeAsync(() => { 10 | configureBasicEditableTestingModule([ImageEditableComponent]); 11 | fixture = TestBed.createComponent(ImageEditableComponent); 12 | component = fixture.componentInstance; 13 | fixture.detectChanges(); 14 | flush(); 15 | fixture.detectChanges(); 16 | })); 17 | 18 | it('The block-card component should be created', fakeAsync(() => { 19 | let blockCardElement: HTMLElement; 20 | blockCardElement = fixture.debugElement.query(By.css('.slate-block-card')).nativeElement; 21 | expect(blockCardElement).toBeTruthy(); 22 | })); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/src/components/block-card/block-card.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'slate-block-card, [slateBlockCard]', 5 | templateUrl: 'block-card.component.html', 6 | standalone: true 7 | }) 8 | export class SlateBlockCard implements OnInit { 9 | @ViewChild('centerContianer', { static: true }) 10 | centerContianer: ElementRef; 11 | 12 | centerRootNodes: HTMLElement[]; 13 | 14 | get nativeElement() { 15 | return this.elementRef.nativeElement; 16 | } 17 | 18 | get centerContainerElement() { 19 | return this.centerContianer.nativeElement as HTMLElement; 20 | } 21 | 22 | constructor(private elementRef: ElementRef) {} 23 | 24 | ngOnInit() { 25 | this.nativeElement.classList.add(`slate-block-card`); 26 | } 27 | 28 | append() { 29 | this.centerRootNodes.forEach( 30 | rootNode => !this.centerContainerElement.contains(rootNode) && this.centerContainerElement.appendChild(rootNode) 31 | ); 32 | } 33 | 34 | initializeCenter(rootNodes: HTMLElement[]) { 35 | this.centerRootNodes = rootNodes; 36 | this.append(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/src/components/children/children-outlet.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ElementRef } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'slate-children-outlet', 5 | template: ``, 6 | changeDetection: ChangeDetectionStrategy.OnPush, 7 | standalone: true 8 | }) 9 | export class SlateChildrenOutlet { 10 | constructor(private elementRef: ElementRef) {} 11 | getNativeElement() { 12 | return this.elementRef.nativeElement; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/src/components/children/children.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; 2 | import { Descendant } from 'slate'; 3 | import { SlateChildrenContext, SlateViewContext } from '../../view/context'; 4 | import { ViewContainer } from '../../view/container'; 5 | 6 | @Component({ 7 | selector: 'slate-children', 8 | template: ``, 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | standalone: true 11 | }) 12 | export class SlateChildren extends ViewContainer { 13 | @Input() children: Descendant[]; 14 | 15 | @Input() context: SlateChildrenContext; 16 | 17 | @Input() viewContext: SlateViewContext; 18 | } 19 | -------------------------------------------------------------------------------- /packages/src/components/editable/editable.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/src/components/element/default-element.component.token.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { ComponentType } from '../../types/view'; 3 | import { BaseElementComponent } from '../../view/base'; 4 | 5 | export const SLATE_DEFAULT_ELEMENT_COMPONENT_TOKEN = new InjectionToken>('slate-default-element-token'); 6 | -------------------------------------------------------------------------------- /packages/src/components/element/default-element.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy } from '@angular/core'; 2 | import { BaseElementComponent } from '../../view/base'; 3 | 4 | @Component({ 5 | selector: 'div[slateDefaultElement]', 6 | template: ``, 7 | changeDetection: ChangeDetectionStrategy.OnPush 8 | }) 9 | export class SlateDefaultElement extends BaseElementComponent {} 10 | -------------------------------------------------------------------------------- /packages/src/components/element/element.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { BaseElementComponent } from '../../view/base'; 3 | 4 | @Component({ 5 | selector: '[slateElement]', 6 | template: '', 7 | changeDetection: ChangeDetectionStrategy.OnPush 8 | }) 9 | export class SlateElement extends BaseElementComponent {} 10 | -------------------------------------------------------------------------------- /packages/src/components/leaf/default-leaf.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; 2 | import { BaseLeafComponent } from '../../view/base'; 3 | import { SlateString } from '../string/string.component'; 4 | 5 | @Component({ 6 | selector: 'span[slateDefaultLeaf]', 7 | template: ``, 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | host: { 10 | 'data-slate-leaf': 'true' 11 | }, 12 | imports: [SlateString] 13 | }) 14 | export class SlateDefaultLeaf extends BaseLeafComponent implements OnDestroy { 15 | onContextChange(): void { 16 | super.onContextChange(); 17 | this.renderPlaceholder(); 18 | } 19 | ngOnDestroy(): void { 20 | // Because the placeholder span is not in the current component, it is destroyed along with the current component 21 | this.destroyPlaceholder(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/src/components/leaf/token.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { ComponentType } from '../../types/view'; 3 | import { BaseLeafComponent } from '../../view/base'; 4 | 5 | export const SLATE_DEFAULT_LEAF_COMPONENT_TOKEN = new InjectionToken>('slate-default-leaf-token'); 6 | -------------------------------------------------------------------------------- /packages/src/components/leaves/leaves.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | ChangeDetectionStrategy, 4 | Component, 5 | Input, 6 | OnChanges, 7 | OnInit, 8 | QueryList, 9 | SimpleChanges, 10 | ViewChildren 11 | } from '@angular/core'; 12 | import { Text } from 'slate'; 13 | import { SlateLeafContext, SlateTextContext } from '../../view/context'; 14 | import { ViewContainer } from '../../view/container'; 15 | 16 | @Component({ 17 | selector: 'slate-leaves', 18 | template: ``, 19 | changeDetection: ChangeDetectionStrategy.OnPush, 20 | imports: [] 21 | }) 22 | export class SlateLeaves extends ViewContainer { 23 | initialized = false; 24 | leafContexts: SlateLeafContext[]; 25 | leaves: Text[]; 26 | 27 | @Input() context: SlateTextContext; 28 | } 29 | -------------------------------------------------------------------------------- /packages/src/components/string/default-string.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; 2 | import { DOMElement } from '../../utils/dom'; 3 | import { BaseComponent } from '../../view/base'; 4 | import { SlateStringContext } from '../../view/context'; 5 | import { BeforeContextChange } from '../../view/context-change'; 6 | 7 | @Component({ 8 | selector: 'span[slateDefaultString]', 9 | template: '', 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | standalone: true 12 | }) 13 | export class SlateDefaultString extends BaseComponent implements OnInit, BeforeContextChange { 14 | textNode?: Text; 15 | brNode?: DOMElement; 16 | 17 | beforeContextChange(value: SlateStringContext) { 18 | if (this.context) { 19 | if (this.context.type === 'lineBreakEmptyString') { 20 | if (value.type === 'string') { 21 | this.removeLineBreakEmptyStringDOM(); 22 | } else { 23 | this.textNode?.remove(); 24 | this.brNode?.remove(); 25 | } 26 | } 27 | if (this.context.type === 'string') { 28 | if (value.type === 'lineBreakEmptyString') { 29 | this.removeStringDOM(); 30 | } 31 | } 32 | } 33 | } 34 | 35 | onContextChange() { 36 | if (this.context.type === 'string') { 37 | this.createStringDOM(); 38 | } else if (this.context.type === 'lineBreakEmptyString') { 39 | this.createLineBreakEmptyStringDOM(); 40 | } 41 | } 42 | 43 | createLineBreakEmptyStringDOM() { 44 | this.nativeElement.setAttribute('data-slate-zero-width', 'n'); 45 | this.nativeElement.setAttribute('data-slate-length', `${this.context.elementStringLength}`); 46 | this.textNode = document.createTextNode(`\uFEFF`); 47 | this.brNode = document.createElement('br'); 48 | this.nativeElement.append(this.textNode, this.brNode); 49 | } 50 | 51 | removeLineBreakEmptyStringDOM() { 52 | this.brNode?.remove(); 53 | // remove zero width character 54 | const zeroWidthCharacterIndex = this.textNode?.textContent.indexOf(`\uFEFF`); 55 | this.textNode?.deleteData(zeroWidthCharacterIndex, 1); 56 | this.nativeElement.removeAttribute('data-slate-zero-width'); 57 | this.nativeElement.removeAttribute('data-slate-length'); 58 | } 59 | 60 | createStringDOM() { 61 | this.nativeElement.setAttribute('data-slate-string', 'true'); 62 | this.updateStringDOM(); 63 | } 64 | 65 | updateStringDOM() { 66 | // Avoid breaking some browser default behaviors, such as spellCheck, android composition input state 67 | if (this.nativeElement.textContent !== this.context.text) { 68 | this.nativeElement.textContent = this.context.text; 69 | } 70 | } 71 | 72 | removeStringDOM() { 73 | this.nativeElement.removeAttribute('data-slate-string'); 74 | this.nativeElement.textContent = ''; 75 | } 76 | 77 | ngOnInit(): void { 78 | this.nativeElement.setAttribute('editable-text', ''); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/src/components/string/string.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; 2 | import { AdvancedEditableComponent, TestingLeafComponent, configureBasicEditableTestingModule, dispatchFakeEvent } from '../../testing'; 3 | import { Editor, Transforms } from 'slate'; 4 | 5 | describe('Default String Render', () => { 6 | let component: AdvancedEditableComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(fakeAsync(() => { 10 | configureBasicEditableTestingModule([AdvancedEditableComponent, TestingLeafComponent], [TestingLeafComponent]); 11 | fixture = TestBed.createComponent(AdvancedEditableComponent); 12 | component = fixture.componentInstance; 13 | })); 14 | 15 | it('should correctly render editable text', fakeAsync(() => { 16 | const text = `Kevin Durant`; 17 | component.value = [ 18 | { 19 | type: 'paragraph', 20 | children: [{ text }], 21 | key: 'KD' 22 | } 23 | ]; 24 | fixture.detectChanges(); 25 | flush(); 26 | fixture.detectChanges(); 27 | const paragraphElement = document.querySelector('[data-slate-node="element"]'); 28 | const editableText = paragraphElement.querySelector('[editable-text]'); 29 | expect(editableText).toBeTruthy(); 30 | expect(editableText.getAttribute('data-slate-string')).toEqual('true'); 31 | expect(editableText.textContent).toEqual(text); 32 | })); 33 | 34 | it('should correctly render editable text with \n', fakeAsync(() => { 35 | const text = `Kevin Durant 36 | Steve Jobs`; 37 | component.value = [ 38 | { 39 | type: 'paragraph', 40 | children: [{ text }], 41 | key: 'KD' 42 | } 43 | ]; 44 | fixture.detectChanges(); 45 | flush(); 46 | fixture.detectChanges(); 47 | const paragraphElement = document.querySelector('[data-slate-node="element"]'); 48 | const editableText = paragraphElement.querySelector('[editable-text]'); 49 | expect(editableText).toBeTruthy(); 50 | expect(editableText.getAttribute('data-slate-string')).toEqual('true'); 51 | expect(editableText.textContent).toEqual(text); 52 | })); 53 | 54 | it('should correctly render line break empty string', fakeAsync(() => { 55 | const text = ``; 56 | component.value = [ 57 | { 58 | type: 'paragraph', 59 | children: [{ text }], 60 | key: 'KD' 61 | } 62 | ]; 63 | fixture.detectChanges(); 64 | flush(); 65 | fixture.detectChanges(); 66 | const paragraphElement = document.querySelector('[data-slate-node="element"]'); 67 | const editableText = paragraphElement.querySelector('[editable-text]'); 68 | expect(editableText).toBeTruthy(); 69 | expect(editableText.childNodes.length).toEqual(2); 70 | expect(editableText.firstChild.textContent).toEqual(`\uFEFF`); 71 | expect(editableText.lastElementChild.tagName).toEqual(`BR`); 72 | })); 73 | 74 | it('should correctly render text when text transform text from empty string to non-empty string', fakeAsync(() => { 75 | const text = ``; 76 | component.value = [ 77 | { 78 | type: 'paragraph', 79 | children: [{ text }], 80 | key: 'KD' 81 | } 82 | ]; 83 | fixture.detectChanges(); 84 | flush(); 85 | fixture.detectChanges(); 86 | const paragraphElement = document.querySelector('[data-slate-node="element"]'); 87 | const editableText = paragraphElement.querySelector('[editable-text]'); 88 | expect(editableText).toBeTruthy(); 89 | expect(editableText.childNodes.length).toEqual(2); 90 | expect(editableText.firstChild.textContent).toEqual(`\uFEFF`); 91 | expect(editableText.lastElementChild.tagName).toEqual(`BR`); 92 | 93 | Transforms.select(component.editor, Editor.end(component.editor, [0])); 94 | 95 | const newText = 'Kevin Durant'; 96 | Transforms.insertText(component.editor, newText); 97 | fixture.detectChanges(); 98 | flush(); 99 | fixture.detectChanges(); 100 | expect(editableText.childNodes.length).toEqual(1); 101 | expect(editableText.firstChild.textContent).toEqual(newText); 102 | })); 103 | 104 | it('should correctly render text when text transform text from non-empty string empty string', fakeAsync(() => { 105 | const text = `Kevin Durant`; 106 | component.value = [ 107 | { 108 | type: 'paragraph', 109 | children: [{ text }], 110 | key: 'KD' 111 | } 112 | ]; 113 | fixture.detectChanges(); 114 | flush(); 115 | fixture.detectChanges(); 116 | const paragraphElement = document.querySelector('[data-slate-node="element"]'); 117 | const editableText = paragraphElement.querySelector('[editable-text]'); 118 | expect(editableText).toBeTruthy(); 119 | expect(editableText.childNodes.length).toEqual(1); 120 | expect(editableText.firstChild.textContent).toEqual(text); 121 | 122 | Transforms.select(component.editor, [0]); 123 | 124 | Transforms.delete(component.editor); 125 | fixture.detectChanges(); 126 | flush(); 127 | fixture.detectChanges(); 128 | expect(editableText.childNodes.length).toEqual(2); 129 | expect(editableText.firstChild.textContent).toEqual(`\uFEFF`); 130 | expect(editableText.lastElementChild.tagName).toEqual(`BR`); 131 | })); 132 | }); 133 | -------------------------------------------------------------------------------- /packages/src/components/string/string.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | ChangeDetectionStrategy, 4 | Component, 5 | ElementRef, 6 | inject, 7 | Input, 8 | OnChanges, 9 | OnInit, 10 | ViewContainerRef 11 | } from '@angular/core'; 12 | import { Node, Text } from 'slate'; 13 | import { ViewContainerItem } from '../../view/container-item'; 14 | import { SlateLeafContext, SlateStringContext } from '../../view/context'; 15 | import { SlateDefaultString } from './default-string.component'; 16 | 17 | @Component({ 18 | selector: 'span[slateString]', 19 | template: '', 20 | changeDetection: ChangeDetectionStrategy.OnPush, 21 | standalone: true 22 | }) 23 | export class SlateString extends ViewContainerItem implements OnInit, OnChanges, AfterViewInit { 24 | @Input() context: SlateLeafContext; 25 | 26 | protected elementRef = inject(ElementRef); 27 | 28 | protected viewContainerRef = inject(ViewContainerRef); 29 | 30 | ngOnInit(): void { 31 | this.createView(); 32 | } 33 | 34 | ngOnChanges() { 35 | if (!this.initialized) { 36 | return; 37 | } 38 | this.updateView(); 39 | } 40 | 41 | ngAfterViewInit() { 42 | this.elementRef.nativeElement.remove(); 43 | } 44 | 45 | // COMPAT: If this is the last text node in an empty block, render a zero- 46 | // width space that will convert into a line break when copying and pasting 47 | // to support expected plain text. 48 | isLineBreakEmptyString() { 49 | return ( 50 | this.context.leaf.text === '' && 51 | this.context.parent.children[this.context.parent.children.length - 1] === this.context.text && 52 | !this.viewContext.editor.isInline(this.context.parent) && 53 | // [list-render] performance optimization: reduce the number of calls to the `Editor.string(editor, path)` method 54 | isEmpty(this.viewContext.editor, this.context.parent) 55 | ); 56 | } 57 | 58 | // COMPAT: If the text is empty, it's because it's on the edge of an inline 59 | // node, so we render a zero-width space so that the selection can be 60 | // inserted next to it still. 61 | isEmptyText() { 62 | return this.context.leaf.text === ''; 63 | } 64 | 65 | // COMPAT: Browsers will collapse trailing new lines at the end of blocks, 66 | // so we need to add an extra trailing new lines to prevent that. 67 | isCompatibleString() { 68 | return this.context.isLast && this.context.leaf.text.slice(-1) === '\n'; 69 | } 70 | 71 | // COMPAT: Render text inside void nodes with a zero-width space. 72 | // So the node can contain selection but the text is not visible. 73 | isVoid() { 74 | return this.viewContext.editor.isVoid(this.context.parent); 75 | } 76 | 77 | getViewType() { 78 | if (this.isVoid()) { 79 | return this.viewContext.templateComponent.voidStringTemplate; 80 | } 81 | 82 | if (this.isLineBreakEmptyString()) { 83 | return SlateDefaultString; 84 | } 85 | 86 | if (this.isEmptyText()) { 87 | return this.viewContext.templateComponent.emptyTextTemplate; 88 | } 89 | 90 | if (this.isCompatibleString()) { 91 | return this.viewContext.templateComponent.compatibleStringTemplate; 92 | } 93 | 94 | return SlateDefaultString; 95 | } 96 | 97 | getType(): SlateStringContext['type'] { 98 | if (this.isLineBreakEmptyString()) { 99 | return 'lineBreakEmptyString'; 100 | } 101 | return 'string'; 102 | } 103 | 104 | getContext(): SlateStringContext { 105 | const stringType = this.getType(); 106 | return { 107 | text: this.context.leaf.text, 108 | elementStringLength: Node.string(this.context.parent).length, 109 | type: stringType 110 | }; 111 | } 112 | 113 | memoizedContext(prev: SlateStringContext, next: SlateStringContext): boolean { 114 | return false; 115 | } 116 | } 117 | 118 | /** 119 | * TODO: remove when bump slate 120 | * copy from slate 121 | * @param editor 122 | * @param element 123 | * @returns 124 | */ 125 | export const isEmpty = (editor, element) => { 126 | const { children } = element; 127 | const [first] = children; 128 | return children.length === 0 || (children.length === 1 && Text.isText(first) && first.text === '' && !editor.isVoid(element)); 129 | }; 130 | -------------------------------------------------------------------------------- /packages/src/components/string/template.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ context.text }}{{ '\uFEFF' }} 6 | 7 | 8 | {{ '\uFEFF' }} 9 | 10 | 11 | {{ '\uFEFF' }} 12 | 13 | -------------------------------------------------------------------------------- /packages/src/components/string/template.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy, ViewChild, TemplateRef } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'slate-string-template', 5 | templateUrl: 'template.component.html', 6 | changeDetection: ChangeDetectionStrategy.OnPush, 7 | standalone: true 8 | }) 9 | export class SlateStringTemplate { 10 | @ViewChild('compatibleStringTemplate', { read: TemplateRef, static: true }) 11 | compatibleStringTemplate: TemplateRef; 12 | 13 | @ViewChild('voidStringTemplate', { read: TemplateRef, static: true }) 14 | voidStringTemplate: TemplateRef; 15 | 16 | @ViewChild('emptyTextTemplate', { read: TemplateRef, static: true }) 17 | emptyTextTemplate: TemplateRef; 18 | } 19 | -------------------------------------------------------------------------------- /packages/src/components/text/default-text.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy } from '@angular/core'; 2 | import { BaseTextComponent } from '../../view/base'; 3 | @Component({ 4 | selector: 'span[slateDefaultText]', 5 | template: ``, 6 | changeDetection: ChangeDetectionStrategy.OnPush, 7 | host: { 8 | 'data-slate-node': 'text' 9 | } 10 | }) 11 | export class SlateDefaultText extends BaseTextComponent {} 12 | -------------------------------------------------------------------------------- /packages/src/components/text/token.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { ComponentType } from '../../types/view'; 3 | import { BaseTextComponent } from '../../view/base'; 4 | 5 | export const SLATE_DEFAULT_TEXT_COMPONENT_TOKEN = new InjectionToken>('slate-default-text-token'); 6 | 7 | export const SLATE_DEFAULT_VOID_TEXT_COMPONENT_TOKEN = new InjectionToken>( 8 | 'slate-default-void-text-token' 9 | ); 10 | -------------------------------------------------------------------------------- /packages/src/components/text/void-text.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy, OnInit, OnChanges } from '@angular/core'; 2 | import { BaseTextComponent } from '../../view/base'; 3 | 4 | @Component({ 5 | selector: 'span[slateVoidText]', 6 | template: ``, 7 | changeDetection: ChangeDetectionStrategy.OnPush, 8 | host: { 9 | 'data-slate-spacer': 'true', 10 | class: 'slate-spacer', 11 | 'data-slate-node': 'text' 12 | } 13 | }) 14 | export class SlateVoidText extends BaseTextComponent implements OnInit, OnChanges { 15 | ngOnInit() { 16 | super.ngOnInit(); 17 | } 18 | 19 | ngOnChanges() { 20 | if (!this.initialized) { 21 | return; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/src/custom-event/DOMTopLevelEventTypes.ts: -------------------------------------------------------------------------------- 1 | export const TOP_BLUR = 'blur'; 2 | export const TOP_COMPOSITION_END = 'compositionend'; 3 | export const TOP_COMPOSITION_START = 'compositionstart'; 4 | export const TOP_COMPOSITION_UPDATE = 'compositionupdate'; 5 | export const TOP_KEY_DOWN = 'keydown'; 6 | export const TOP_KEY_PRESS = 'keypress'; 7 | export const TOP_KEY_UP = 'keyup'; 8 | export const TOP_MOUSE_DOWN = 'mousedown'; 9 | export const TOP_MOUSE_MOVE = 'mousemove'; 10 | export const TOP_MOUSE_OUT = 'mouseout'; 11 | export const TOP_TEXT_INPUT = 'textInput'; 12 | export const TOP_PASTE = 'paste'; 13 | -------------------------------------------------------------------------------- /packages/src/custom-event/FallbackCompositionState.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /** 9 | * These variables store information about text content of a target node, 10 | * allowing comparison of content before and after a given event. 11 | * 12 | * Identify the node where selection currently begins, then observe 13 | * both its text content and its current position in the DOM. Since the 14 | * browser may natively replace the target node during composition, we can 15 | * use its position to find its replacement. 16 | * 17 | * 18 | */ 19 | 20 | let root = null; 21 | let startText = null; 22 | let fallbackText = null; 23 | 24 | export function initialize(nativeEventTarget) { 25 | root = nativeEventTarget; 26 | startText = getText(); 27 | return true; 28 | } 29 | 30 | export function reset() { 31 | root = null; 32 | startText = null; 33 | fallbackText = null; 34 | } 35 | 36 | export function getData() { 37 | if (fallbackText) { 38 | return fallbackText; 39 | } 40 | 41 | let start; 42 | const startValue = startText; 43 | const startLength = startValue.length; 44 | let end; 45 | const endValue = getText(); 46 | const endLength = endValue.length; 47 | 48 | for (start = 0; start < startLength; start++) { 49 | if (startValue[start] !== endValue[start]) { 50 | break; 51 | } 52 | } 53 | 54 | const minEnd = startLength - start; 55 | for (end = 1; end <= minEnd; end++) { 56 | if (startValue[startLength - end] !== endValue[endLength - end]) { 57 | break; 58 | } 59 | } 60 | 61 | const sliceTail = end > 1 ? 1 - end : undefined; 62 | fallbackText = endValue.slice(start, sliceTail); 63 | return fallbackText; 64 | } 65 | 66 | export function getText() { 67 | if ('value' in root) { 68 | return root.value; 69 | } 70 | return root.textContent; 71 | } 72 | -------------------------------------------------------------------------------- /packages/src/custom-event/before-input-polyfill.ts: -------------------------------------------------------------------------------- 1 | export const BEFORE_INPUT_EVENTS: { 2 | name: string; 3 | handler: string; 4 | isTriggerBeforeInput: boolean; 5 | }[] = [ 6 | // { name: 'blur', handler: 'onBlur', isTriggerBeforeInput: true }, 7 | // { name: 'compositionstart', handler: 'onCompositionStart', isTriggerBeforeInput: true }, 8 | { name: 'compositionupdate', handler: null, isTriggerBeforeInput: true }, 9 | // { name: 'compositionend', handler: 'onCompositionEnd', isTriggerBeforeInput: false }, 10 | // { name: 'keydown', handler: 'onKeyDown', isTriggerBeforeInput: true }, 11 | { name: 'keypress', handler: null, isTriggerBeforeInput: true }, 12 | { name: 'keyup', handler: 'onKeyUp', isTriggerBeforeInput: true }, 13 | { name: 'mousedown', handler: 'onMouseDown', isTriggerBeforeInput: true }, 14 | { name: 'textInput', handler: null, isTriggerBeforeInput: true } 15 | // { name: 'paste', handler: 'onPaste', isTriggerBeforeInput: true } 16 | ]; 17 | -------------------------------------------------------------------------------- /packages/src/module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { SlateEditable } from './components/editable/editable.component'; 4 | import { SlateDefaultText } from './components/text/default-text.component'; 5 | import { SlateVoidText } from './components/text/void-text.component'; 6 | import { SlateElement } from './components/element/element.component'; 7 | import { SlateDefaultElement } from './components/element/default-element.component'; 8 | import { SlateString } from './components/string/string.component'; 9 | import { SlateStringTemplate } from './components/string/template.component'; 10 | import { SlateChildren } from './components/children/children.component'; 11 | import { SlateChildrenOutlet } from './components/children/children-outlet.component'; 12 | import { SlateBlockCard } from './components/block-card/block-card.component'; 13 | import { SlateDefaultLeaf } from './components/leaf/default-leaf.component'; 14 | import { SlateLeaves } from './components/leaves/leaves.component'; 15 | import { SLATE_DEFAULT_ELEMENT_COMPONENT_TOKEN } from './components/element/default-element.component.token'; 16 | import { SlateDefaultString } from './components/string/default-string.component'; 17 | 18 | @NgModule({ 19 | imports: [ 20 | CommonModule, 21 | SlateEditable, 22 | SlateDefaultElement, 23 | SlateElement, 24 | SlateVoidText, 25 | SlateDefaultText, 26 | SlateString, 27 | SlateStringTemplate, 28 | SlateChildren, 29 | SlateBlockCard, 30 | SlateLeaves, 31 | SlateDefaultLeaf, 32 | SlateDefaultString, 33 | SlateChildrenOutlet 34 | ], 35 | exports: [SlateEditable, SlateChildren, SlateChildrenOutlet, SlateElement, SlateLeaves, SlateString, SlateDefaultString], 36 | providers: [ 37 | { 38 | provide: SLATE_DEFAULT_ELEMENT_COMPONENT_TOKEN, 39 | useValue: SlateDefaultElement 40 | } 41 | ] 42 | }) 43 | export class SlateModule {} 44 | -------------------------------------------------------------------------------- /packages/src/plugins/angular-editor.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, ComponentFixture, tick, fakeAsync, flush } from '@angular/core/testing'; 2 | import { AngularEditor } from './angular-editor'; 3 | import { BasicEditableComponent, configureBasicEditableTestingModule } from '../testing'; 4 | import { Transforms, Element } from 'slate'; 5 | import { createEmptyDocument } from 'slate-angular/testing/create-document'; 6 | 7 | describe('AngularEditor', () => { 8 | let component: BasicEditableComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(fakeAsync(() => { 12 | configureBasicEditableTestingModule([BasicEditableComponent]); 13 | fixture = TestBed.createComponent(BasicEditableComponent); 14 | component = fixture.componentInstance; 15 | component.value = createEmptyDocument() as Element[]; 16 | fixture.detectChanges(); 17 | flush(); 18 | fixture.detectChanges(); 19 | })); 20 | 21 | afterEach(() => { 22 | fixture.destroy(); 23 | }); 24 | 25 | it('should fixed cursor after zero width char when text node is empty', () => { 26 | Transforms.select(component.editor, { 27 | anchor: { 28 | path: [0, 0], 29 | offset: 0 30 | }, 31 | focus: { 32 | path: [0, 0], 33 | offset: 0 34 | } 35 | }); 36 | const nativeRange = AngularEditor.toDOMRange(component.editor, component.editor.selection); 37 | expect(nativeRange.startOffset).toEqual(1); 38 | expect(nativeRange.endOffset).toEqual(1); 39 | }); 40 | 41 | it('should fixed cursor to location after inserted text when insertText', fakeAsync(() => { 42 | const insertText = 'test'; 43 | Transforms.select(component.editor, { 44 | anchor: { 45 | path: [0, 0], 46 | offset: 0 47 | }, 48 | focus: { 49 | path: [0, 0], 50 | offset: 0 51 | } 52 | }); 53 | tick(100); 54 | Transforms.insertText(component.editor, insertText); 55 | tick(100); 56 | const nativeRange = AngularEditor.toDOMRange(component.editor, component.editor.selection); 57 | expect(nativeRange.startOffset).toEqual(insertText.length); 58 | expect(nativeRange.endOffset).toEqual(insertText.length); 59 | })); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of slate-angular 3 | */ 4 | export * from './plugins/angular-editor'; 5 | export * from './plugins/with-angular'; 6 | export * from './components/editable/editable.component'; 7 | export * from './components/element/element.component'; 8 | export * from './components/string/string.component'; 9 | export * from './components/string/default-string.component'; 10 | export * from './components/children/children.component'; 11 | export * from './components/children/children-outlet.component'; 12 | export * from './components/leaves/leaves.component'; 13 | export * from './module'; 14 | export * from './types/error'; 15 | export * from './view/base'; 16 | export * from './view/context'; 17 | export * from './view/context-change'; 18 | export * from './utils'; 19 | export * from './types'; 20 | -------------------------------------------------------------------------------- /packages/src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @use '../components/block-card/block-card.component.scss'; 2 | .slate-editable-container { 3 | display: block; 4 | outline: none; 5 | padding: 32px; 6 | white-space: break-spaces; 7 | & [contenteditable='true'] { 8 | outline: none; 9 | } 10 | & [data-slate-placeholder] { 11 | position: absolute; 12 | pointer-events: none; 13 | width: 100%; 14 | max-width: 100%; 15 | display: block; 16 | opacity: 0.333; 17 | user-select: none; 18 | text-decoration: none; 19 | top: 0; 20 | } 21 | & [data-slate-leaf='true'] { 22 | &.leaf-with-placeholder { 23 | position: relative; 24 | display: inline-block; 25 | width: 100%; 26 | } 27 | } 28 | &.firefox { 29 | // Compatible for firefox, there are two problems with using inline-block 30 | // Issue-1: paragraph height becomes taller 31 | // Issue-2: blocks focus movement on key down 32 | .leaf-with-placeholder { 33 | display: inline-flex !important; 34 | } 35 | } 36 | } 37 | 38 | .slate-spacer { 39 | height: 0; 40 | color: transparent; 41 | outline: none; 42 | position: absolute; 43 | } 44 | -------------------------------------------------------------------------------- /packages/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 { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 7 | 8 | // First, initialize the Angular testing environment. 9 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 10 | teardown: { destroyAfterEach: false } 11 | }); 12 | -------------------------------------------------------------------------------- /packages/src/testing/advanced-editable.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild } from '@angular/core'; 2 | import { createEditor, Element, NodeEntry, Text } from 'slate'; 3 | import { SlateEditable } from '../components/editable/editable.component'; 4 | import { withAngular } from '../plugins/with-angular'; 5 | import { createDefaultDocument } from './create-document'; 6 | import { TestingLeafComponent } from './leaf.component'; 7 | import { AngularEditor } from '../plugins/angular-editor'; 8 | import { DOMRange } from '../utils/dom'; 9 | 10 | @Component({ 11 | selector: 'basic-editable', 12 | template: ` 13 | 22 | `, 23 | standalone: false 24 | }) 25 | export class AdvancedEditableComponent implements OnInit { 26 | editor = withAngular(createEditor()); 27 | 28 | value: any = createDefaultDocument(); 29 | 30 | decorate = (nodeEntry: NodeEntry) => []; 31 | 32 | trackBy = (element: Element) => null; 33 | 34 | placeholder: string; 35 | 36 | @ViewChild(SlateEditable, { static: true }) 37 | editableComponent: SlateEditable; 38 | 39 | generateDecorate(keywords: string) { 40 | this.decorate = ([node, path]) => { 41 | const ranges = []; 42 | 43 | if (keywords && Text.isText(node)) { 44 | const { text } = node; 45 | const parts = text.split(keywords); 46 | let offset = 0; 47 | 48 | parts.forEach((part, i) => { 49 | if (i !== 0) { 50 | ranges.push({ 51 | anchor: { path, offset: offset - keywords.length }, 52 | focus: { path, offset }, 53 | highlight: true 54 | }); 55 | } 56 | 57 | offset = offset + part.length + keywords.length; 58 | }); 59 | } 60 | 61 | return ranges; 62 | }; 63 | } 64 | 65 | renderLeaf = (text: Text) => { 66 | if (text['highlight']) { 67 | return TestingLeafComponent; 68 | } 69 | return null; 70 | }; 71 | 72 | scrollSelectionIntoView = (editor: AngularEditor, domRange: DOMRange) => {}; 73 | 74 | ngOnInit() {} 75 | 76 | constructor() {} 77 | } 78 | -------------------------------------------------------------------------------- /packages/src/testing/basic-editable.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ViewChild } from '@angular/core'; 2 | import { createEditor, Element } from 'slate'; 3 | import { SlateEditable } from '../components/editable/editable.component'; 4 | import { withAngular } from '../plugins/with-angular'; 5 | import { createDefaultDocument } from './create-document'; 6 | @Component({ 7 | selector: 'basic-editable', 8 | template: ` `, 9 | standalone: false 10 | }) 11 | export class BasicEditableComponent { 12 | editor = withAngular(createEditor()); 13 | 14 | value: Element[] = createDefaultDocument() as Element[]; 15 | 16 | @ViewChild(SlateEditable, { static: true }) 17 | editableComponent: SlateEditable; 18 | 19 | ngModelChange() {} 20 | 21 | constructor() {} 22 | } 23 | -------------------------------------------------------------------------------- /packages/src/testing/create-document.ts: -------------------------------------------------------------------------------- 1 | export function createEmptyDocument() { 2 | return [ 3 | { 4 | type: 'paragraph', 5 | children: [{ text: '' }] 6 | } 7 | ]; 8 | } 9 | export function createDefaultDocument() { 10 | return [ 11 | { 12 | type: 'paragraph', 13 | children: [{ text: 'This is editable text!' }] 14 | } 15 | ]; 16 | } 17 | 18 | export function createMultipleParagraph() { 19 | return [ 20 | { 21 | type: 'paragraph', 22 | children: [{ text: '0' }] 23 | }, 24 | { 25 | type: 'paragraph', 26 | children: [{ text: '1' }] 27 | }, 28 | { 29 | type: 'paragraph', 30 | children: [{ text: '2' }] 31 | }, 32 | { 33 | type: 'paragraph', 34 | children: [{ text: '3' }] 35 | }, 36 | { 37 | type: 'paragraph', 38 | children: [{ text: '4' }] 39 | }, 40 | { 41 | type: 'paragraph', 42 | children: [{ text: '5' }] 43 | } 44 | ]; 45 | } 46 | -------------------------------------------------------------------------------- /packages/src/testing/dispatcher-events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | import { createFakeEvent, createKeyboardEvent, createMouseEvent, createTouchEvent } from './events'; 9 | import { ModifierKeys } from './types'; 10 | 11 | /** 12 | * Utility to dispatch any event on a Node. 13 | * @docs-private 14 | */ 15 | export function dispatchEvent(node: Node | Window, event: Event): Event { 16 | node.dispatchEvent(event); 17 | return event; 18 | } 19 | 20 | /** 21 | * Shorthand to dispatch a fake event on a specified node. 22 | * @docs-private 23 | */ 24 | export function dispatchFakeEvent(node: Node | Window, type: string, canBubble?: boolean): Event { 25 | return dispatchEvent(node, createFakeEvent(type, canBubble)); 26 | } 27 | 28 | /** 29 | * Shorthand to dispatch a keyboard event with a specified key code. 30 | * @docs-private 31 | */ 32 | export function dispatchKeyboardEvent( 33 | node: Node, 34 | type: string, 35 | keyCode?: number, 36 | key?: string, 37 | target?: Element, 38 | modifiers?: ModifierKeys 39 | ): KeyboardEvent { 40 | return dispatchEvent(node, createKeyboardEvent(type, keyCode, key, target, modifiers)) as KeyboardEvent; 41 | } 42 | 43 | /** 44 | * Shorthand to dispatch a mouse event on the specified coordinates. 45 | * @docs-private 46 | */ 47 | export function dispatchMouseEvent(node: Node, type: string, x = 0, y = 0, event = createMouseEvent(type, x, y)): MouseEvent { 48 | return dispatchEvent(node, event) as MouseEvent; 49 | } 50 | 51 | /** 52 | * Shorthand to dispatch a touch event on the specified coordinates. 53 | * @docs-private 54 | */ 55 | export function dispatchTouchEvent(node: Node, type: string, x = 0, y = 0) { 56 | return dispatchEvent(node, createTouchEvent(type, x, y)); 57 | } 58 | -------------------------------------------------------------------------------- /packages/src/testing/editable-with-outlet.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ViewChild } from '@angular/core'; 2 | import { createEditor, Element } from 'slate'; 3 | import { SlateEditable } from '../components/editable/editable.component'; 4 | import { withAngular } from '../plugins/with-angular'; 5 | import { SlateChildrenOutlet } from '../components/children/children-outlet.component'; 6 | import { BaseElementComponent } from '../view/base'; 7 | 8 | const customType = 'custom-with-outlet'; 9 | 10 | @Component({ 11 | selector: 'editable-with-outlet', 12 | template: ` `, 18 | standalone: false 19 | }) 20 | export class EditableWithOutletComponent { 21 | editor = withAngular(createEditor()); 22 | 23 | value: Element[] = createDefaultDocument() as Element[]; 24 | 25 | @ViewChild(SlateEditable, { static: true }) 26 | editableComponent: SlateEditable; 27 | 28 | renderElement() { 29 | return (element: Element) => { 30 | if ((element.type as any) === customType) { 31 | return TestElementWithOutletComponent; 32 | } 33 | return null; 34 | }; 35 | } 36 | 37 | ngModelChange() {} 38 | 39 | constructor() {} 40 | } 41 | 42 | export function createDefaultDocument() { 43 | return [ 44 | { 45 | type: customType, 46 | children: [ 47 | { 48 | type: 'paragraph', 49 | children: [{ text: 'This is editable text!' }] 50 | } 51 | ] 52 | } 53 | ]; 54 | } 55 | 56 | @Component({ 57 | selector: 'div[test-element-with-outlet]', 58 | template: ` 59 |
          before
          60 | 61 |
          after
          62 | `, 63 | host: { 64 | class: 'test-element-with-outlet' 65 | }, 66 | imports: [SlateChildrenOutlet] 67 | }) 68 | export class TestElementWithOutletComponent extends BaseElementComponent {} 69 | -------------------------------------------------------------------------------- /packages/src/testing/element-focus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { dispatchFakeEvent } from './dispatcher-events'; 10 | 11 | /** 12 | * Patches an elements focus and blur methods to emit events consistently and predictably. 13 | * This is necessary, because some browsers, like IE11, will call the focus handlers asynchronously, 14 | * while others won't fire them at all if the browser window is not focused. 15 | */ 16 | export function patchElementFocus(element: HTMLElement) { 17 | element.focus = () => dispatchFakeEvent(element, 'focus'); 18 | element.blur = () => dispatchFakeEvent(element, 'blur'); 19 | } 20 | -------------------------------------------------------------------------------- /packages/src/testing/events.ts: -------------------------------------------------------------------------------- 1 | import { ModifierKeys } from './types'; 2 | 3 | /** 4 | * Creates a browser MouseEvent with the specified options. 5 | * @docs-private 6 | */ 7 | export function createMouseEvent(type: string, x = 0, y = 0, button = 0) { 8 | const event = document.createEvent('MouseEvent'); 9 | const originalPreventDefault = event.preventDefault.bind(event); 10 | 11 | event.initMouseEvent( 12 | type, 13 | true /* canBubble */, 14 | true /* cancelable */, 15 | window /* view */, 16 | 0 /* detail */, 17 | x /* screenX */, 18 | y /* screenY */, 19 | x /* clientX */, 20 | y /* clientY */, 21 | false /* ctrlKey */, 22 | false /* altKey */, 23 | false /* shiftKey */, 24 | false /* metaKey */, 25 | button /* button */, 26 | null /* relatedTarget */ 27 | ); 28 | 29 | // `initMouseEvent` doesn't allow us to pass the `buttons` and 30 | // defaults it to 0 which looks like a fake event. 31 | Object.defineProperty(event, 'buttons', { get: () => 1 }); 32 | 33 | // IE won't set `defaultPrevented` on synthetic events so we need to do it manually. 34 | event.preventDefault = function () { 35 | Object.defineProperty(event, 'defaultPrevented', { get: () => true }); 36 | return originalPreventDefault(); 37 | }; 38 | 39 | return event; 40 | } 41 | 42 | /** 43 | * Creates a browser TouchEvent with the specified pointer coordinates. 44 | * @docs-private 45 | */ 46 | export function createTouchEvent(type: string, pageX = 0, pageY = 0) { 47 | // In favor of creating events that work for most of the browsers, the event is created 48 | // as a basic UI Event. The necessary details for the event will be set manually. 49 | const event = document.createEvent('UIEvent'); 50 | const touchDetails = { pageX, pageY }; 51 | 52 | // TS3.6 removes the initUIEvent method and suggests porting to "new UIEvent()". 53 | (event as any).initUIEvent(type, true, true, window, 0); 54 | 55 | // Most of the browsers don't have a "initTouchEvent" method that can be used to define 56 | // the touch details. 57 | Object.defineProperties(event, { 58 | touches: { value: [touchDetails] }, 59 | targetTouches: { value: [touchDetails] }, 60 | changedTouches: { value: [touchDetails] } 61 | }); 62 | 63 | return event; 64 | } 65 | 66 | /** 67 | * Dispatches a keydown event from an element. 68 | * @docs-private 69 | */ 70 | export function createKeyboardEvent(type: string, keyCode: number = 0, key: string = '', target?: Element, modifiers: ModifierKeys = {}) { 71 | const event = document.createEvent('KeyboardEvent') as any; 72 | const originalPreventDefault = event.preventDefault; 73 | 74 | // Firefox does not support `initKeyboardEvent`, but supports `initKeyEvent`. 75 | if (event.initKeyEvent) { 76 | event.initKeyEvent(type, true, true, window, modifiers.control, modifiers.alt, modifiers.shift, modifiers.meta, keyCode); 77 | } else { 78 | // `initKeyboardEvent` expects to receive modifiers as a whitespace-delimited string 79 | // See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/initKeyboardEvent 80 | let modifiersList = ''; 81 | 82 | if (modifiers.control) { 83 | modifiersList += 'Control '; 84 | } 85 | 86 | if (modifiers.alt) { 87 | modifiersList += 'Alt '; 88 | } 89 | 90 | if (modifiers.shift) { 91 | modifiersList += 'Shift '; 92 | } 93 | 94 | if (modifiers.meta) { 95 | modifiersList += 'Meta '; 96 | } 97 | 98 | event.initKeyboardEvent( 99 | type, 100 | true /* canBubble */, 101 | true /* cancelable */, 102 | window /* view */, 103 | 0 /* char */, 104 | key /* key */, 105 | 0 /* location */, 106 | modifiersList.trim() /* modifiersList */, 107 | false /* repeat */ 108 | ); 109 | } 110 | 111 | // Webkit Browsers don't set the keyCode when calling the init function. 112 | // See related bug https://bugs.webkit.org/show_bug.cgi?id=16735 113 | Object.defineProperties(event, { 114 | keyCode: { get: () => keyCode }, 115 | key: { get: () => key }, 116 | target: { get: () => target }, 117 | ctrlKey: { get: () => !!modifiers.control }, 118 | altKey: { get: () => !!modifiers.alt }, 119 | shiftKey: { get: () => !!modifiers.shift }, 120 | metaKey: { get: () => !!modifiers.meta } 121 | }); 122 | 123 | // IE won't set `defaultPrevented` on synthetic events so we need to do it manually. 124 | event.preventDefault = function () { 125 | Object.defineProperty(event, 'defaultPrevented', { get: () => true }); 126 | return originalPreventDefault.apply(this, arguments); 127 | }; 128 | 129 | return event; 130 | } 131 | 132 | /** 133 | * Creates a fake event object with any desired event type. 134 | * @docs-private 135 | */ 136 | export function createFakeEvent(type: string, canBubble = false, cancelable = true) { 137 | const event = document.createEvent('Event'); 138 | event.initEvent(type, canBubble, cancelable); 139 | return event; 140 | } 141 | -------------------------------------------------------------------------------- /packages/src/testing/image-editable.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild } from '@angular/core'; 2 | import { createEditor, Editor, Element, Node } from 'slate'; 3 | import { SlateEditable } from '../components/editable/editable.component'; 4 | import { withAngular } from '../plugins/with-angular'; 5 | 6 | @Component({ 7 | selector: 'image-editable', 8 | template: ` `, 9 | standalone: false 10 | }) 11 | export class ImageEditableComponent implements OnInit { 12 | editor = withImage(withAngular(createEditor())); 13 | 14 | value = [ 15 | { 16 | type: 'image', 17 | url: 'https://source.unsplash.com/kFrdX5IeQzI', 18 | children: [ 19 | { 20 | text: '' 21 | } 22 | ] 23 | } 24 | ]; 25 | 26 | @ViewChild(SlateEditable, { static: true }) 27 | editableComponent: SlateEditable; 28 | 29 | ngOnInit() {} 30 | 31 | constructor() {} 32 | } 33 | 34 | const withImage = (editor: Editor) => { 35 | const { isBlockCard, isVoid } = editor; 36 | editor.isBlockCard = (node: Element) => { 37 | if (Element.isElement(node) && node.type === 'image') { 38 | return true; 39 | } 40 | return isBlockCard(node); 41 | }; 42 | editor.isVoid = (node: Element) => { 43 | if (Element.isElement(node) && node.type === 'image') { 44 | return true; 45 | } 46 | return isVoid(node); 47 | }; 48 | return editor; 49 | }; 50 | -------------------------------------------------------------------------------- /packages/src/testing/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dispatcher-events'; 2 | export * from './events'; 3 | export * from './element-focus'; 4 | export * from './module'; 5 | export * from './basic-editable.component'; 6 | export * from './advanced-editable.component'; 7 | export * from './image-editable.component'; 8 | export * from './editable-with-outlet.component'; 9 | export * from './leaf.component'; 10 | -------------------------------------------------------------------------------- /packages/src/testing/leaf.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, Renderer2 } from '@angular/core'; 2 | import { BaseLeafComponent } from 'slate-angular'; 3 | 4 | @Component({ 5 | selector: 'span[testingLeaf]', 6 | template: ` `, 7 | host: { 8 | class: 'testing-leaf' 9 | }, 10 | standalone: false 11 | }) 12 | export class TestingLeafComponent extends BaseLeafComponent { 13 | private renderer = inject(Renderer2); 14 | 15 | onContextChange() { 16 | super.onContextChange(); 17 | this.changeStyle(); 18 | } 19 | 20 | changeStyle() { 21 | const backgroundColor = this.leaf['highlight'] ? '#ffeeba' : null; 22 | this.renderer.setStyle(this.nativeElement, 'backgroundColor', backgroundColor); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/src/testing/module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Provider } from '@angular/core'; 3 | import { TestBed } from '@angular/core/testing'; 4 | import { BrowserModule } from '@angular/platform-browser'; 5 | import { SlateModule } from '../module'; 6 | import { FormsModule } from '@angular/forms'; 7 | 8 | export function configureBasicEditableTestingModule(declarations: any[], entryComponents: any[] = [], providers: Provider[] = []) { 9 | TestBed.configureTestingModule({ 10 | declarations: declarations, 11 | imports: [CommonModule, BrowserModule, SlateModule, FormsModule], 12 | providers: [...providers], 13 | teardown: { destroyAfterEach: false } 14 | }).compileComponents(); 15 | } 16 | -------------------------------------------------------------------------------- /packages/src/testing/types.ts: -------------------------------------------------------------------------------- 1 | export interface ModifierKeys { 2 | control?: boolean; 3 | alt?: boolean; 4 | shift?: boolean; 5 | meta?: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /packages/src/types/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { Element } from 'slate'; 2 | 3 | export interface ClipboardData { 4 | files?: File[]; 5 | elements?: Element[]; 6 | text?: string; 7 | html?: string; 8 | } 9 | 10 | export type OriginEvent = 'drag' | 'copy' | 'cut'; 11 | -------------------------------------------------------------------------------- /packages/src/types/error.ts: -------------------------------------------------------------------------------- 1 | import { Descendant } from 'slate'; 2 | 3 | export enum SlateErrorCode { 4 | ToNativeSelectionError = 2100, 5 | ToSlateSelectionError = 2101, 6 | OnDOMBeforeInputError = 2102, 7 | OnSyntheticBeforeInputError = 2103, 8 | OnDOMKeydownError = 2104, 9 | GetStartPointError = 2105, 10 | NotFoundPreviousRootNodeError = 3100, 11 | InvalidValueError = 4100 12 | } 13 | 14 | export interface SlateError { 15 | code?: SlateErrorCode | number; 16 | name?: string; 17 | nativeError?: Error; 18 | data?: Descendant[]; 19 | } 20 | -------------------------------------------------------------------------------- /packages/src/types/feature.ts: -------------------------------------------------------------------------------- 1 | import { BaseRange } from 'slate'; 2 | 3 | export interface SlatePlaceholder extends BaseRange { 4 | placeholder: string; 5 | } 6 | -------------------------------------------------------------------------------- /packages/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error'; 2 | export * from './view'; 3 | export * from './feature'; 4 | export * from './clipboard'; 5 | 6 | export type SafeAny = any; 7 | -------------------------------------------------------------------------------- /packages/src/types/view.ts: -------------------------------------------------------------------------------- 1 | import { TemplateRef } from '@angular/core'; 2 | 3 | export interface ComponentType { 4 | new (...args: any[]): T; 5 | } 6 | 7 | export type ViewType = TemplateRef | ComponentType; 8 | -------------------------------------------------------------------------------- /packages/src/utils/block-card.ts: -------------------------------------------------------------------------------- 1 | import { DOMNode, DOMSelection } from './dom'; 2 | 3 | export const FAKE_LEFT_BLOCK_CARD_OFFSET = -1; 4 | 5 | export const FAKE_RIGHT_BLOCK_CARD_OFFSET = -2; 6 | 7 | export function hasBlockCardWithNode(node: DOMNode) { 8 | return node && (node.parentElement.hasAttribute('card-target') || (node instanceof HTMLElement && node.hasAttribute('card-target'))); 9 | } 10 | 11 | export function hasBlockCard(selection: DOMSelection) { 12 | return hasBlockCardWithNode(selection?.anchorNode) || hasBlockCardWithNode(selection?.focusNode); 13 | } 14 | 15 | export function getCardTargetAttribute(node: DOMNode) { 16 | return node.parentElement.attributes['card-target'] || (node instanceof HTMLElement && node.attributes['card-target']); 17 | } 18 | 19 | export function isCardLeft(node: DOMNode) { 20 | const cardTarget = getCardTargetAttribute(node); 21 | return cardTarget && cardTarget.nodeValue === 'card-left'; 22 | } 23 | 24 | export function isCardLeftByTargetAttr(targetAttr: any) { 25 | return targetAttr && targetAttr.nodeValue === 'card-left'; 26 | } 27 | 28 | export function isCardRightByTargetAttr(targetAttr: any) { 29 | return targetAttr && targetAttr.nodeValue === 'card-right'; 30 | } 31 | 32 | export function isCardCenterByTargetAttr(targetAttr: any) { 33 | return targetAttr && targetAttr.nodeValue === 'card-center'; 34 | } 35 | -------------------------------------------------------------------------------- /packages/src/utils/clipboard/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { Element } from 'slate'; 2 | import { ClipboardData } from '../../types/clipboard'; 3 | import { SlateFragmentAttributeKey, getSlateFragmentAttribute } from '../dom'; 4 | import { isClipboardReadSupported, isClipboardWriteSupported, isClipboardWriteTextSupported } from './common'; 5 | import { getDataTransferClipboard, setDataTransferClipboard, setDataTransferClipboardText } from './data-transfer'; 6 | import { getNavigatorClipboard, setNavigatorClipboard } from './navigator-clipboard'; 7 | 8 | export const buildHTMLText = (wrapper: HTMLElement, attach: HTMLElement, data: Element[]) => { 9 | const stringObj = JSON.stringify(data); 10 | const encoded = window.btoa(encodeURIComponent(stringObj)); 11 | attach.setAttribute(SlateFragmentAttributeKey, encoded); 12 | return wrapper.innerHTML; 13 | }; 14 | 15 | export const getClipboardFromHTMLText = (html: string): ClipboardData => { 16 | const fragmentAttribute = getSlateFragmentAttribute(html); 17 | if (fragmentAttribute) { 18 | try { 19 | const decoded = decodeURIComponent(window.atob(fragmentAttribute)); 20 | const result = JSON.parse(decoded); 21 | if (result && Array.isArray(result) && result.length > 0) { 22 | return { 23 | elements: result 24 | }; 25 | } 26 | } catch (error) { 27 | console.error(error); 28 | return null; 29 | } 30 | } 31 | return null; 32 | }; 33 | 34 | export const createClipboardData = (html: string, elements: Element[], text: string, files: File[]): ClipboardData => { 35 | const data = { elements, text, html, files }; 36 | return data; 37 | }; 38 | 39 | export const getClipboardData = async (dataTransfer?: DataTransfer): Promise => { 40 | let clipboardData = null; 41 | if (dataTransfer) { 42 | let filesData = {}; 43 | if (dataTransfer.files.length) { 44 | filesData = { ...filesData, files: Array.from(dataTransfer.files) }; 45 | } 46 | clipboardData = getDataTransferClipboard(dataTransfer); 47 | return { ...clipboardData, ...filesData }; 48 | } 49 | if (isClipboardReadSupported()) { 50 | return await getNavigatorClipboard(); 51 | } 52 | return clipboardData; 53 | }; 54 | 55 | /** 56 | * @param wrapper get wrapper.innerHTML string which will be written in clipboard 57 | * @param attach attach must be child element of wrapper which will be attached json data 58 | * @returns void 59 | */ 60 | export const setClipboardData = async ( 61 | clipboardData: ClipboardData, 62 | wrapper: HTMLElement, 63 | attach: HTMLElement, 64 | dataTransfer?: Pick 65 | ) => { 66 | if (!clipboardData) { 67 | return; 68 | } 69 | const { elements, text } = clipboardData; 70 | if (isClipboardWriteSupported()) { 71 | const htmlText = buildHTMLText(wrapper, attach, elements); 72 | // TODO 73 | // maybe fail to write when copy some cell in table 74 | return await setNavigatorClipboard(htmlText, elements, text); 75 | } 76 | 77 | if (dataTransfer) { 78 | const htmlText = buildHTMLText(wrapper, attach, elements); 79 | setDataTransferClipboard(dataTransfer, htmlText); 80 | setDataTransferClipboardText(dataTransfer, text); 81 | return; 82 | } 83 | 84 | // Compatible with situations where navigator.clipboard.write is not supported and dataTransfer is empty 85 | // Such as contextmenu copy in Firefox. 86 | if (isClipboardWriteTextSupported()) { 87 | const htmlText = buildHTMLText(wrapper, attach, elements); 88 | return await navigator.clipboard.writeText(htmlText); 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /packages/src/utils/clipboard/common.ts: -------------------------------------------------------------------------------- 1 | export const isClipboardReadSupported = () => { 2 | return 'clipboard' in navigator && 'read' in navigator.clipboard; 3 | }; 4 | 5 | export const isClipboardWriteSupported = () => { 6 | return 'clipboard' in navigator && 'write' in navigator.clipboard; 7 | }; 8 | 9 | export const isClipboardWriteTextSupported = () => { 10 | return 'clipboard' in navigator && 'writeText' in navigator.clipboard; 11 | }; 12 | 13 | export const isClipboardFile = (item: ClipboardItem) => { 14 | return item.types.find(i => i.match(/^image\//)); 15 | }; 16 | 17 | export const isInvalidTable = (nodes: Element[] = []) => { 18 | return nodes.some(node => node.tagName.toLowerCase() === 'tr'); 19 | }; 20 | 21 | export const stripHtml = (html: string) => { 22 | // See 23 | const doc = document.implementation.createHTMLDocument(''); 24 | doc.documentElement.innerHTML = html.trim(); 25 | return doc.body.textContent || doc.body.innerText || ''; 26 | }; 27 | 28 | export const blobAsString = (blob: Blob) => { 29 | return new Promise((resolve, reject) => { 30 | const reader = new FileReader(); 31 | reader.addEventListener('loadend', () => { 32 | const text = reader.result; 33 | resolve(text as string); 34 | }); 35 | reader.addEventListener('error', () => { 36 | reject(reader.error); 37 | }); 38 | reader.readAsText(blob); 39 | }); 40 | }; 41 | 42 | export const completeTable = (fragment: DocumentFragment) => { 43 | const result = document.createDocumentFragment(); 44 | const table = document.createElement('table'); 45 | result.appendChild(table); 46 | table.appendChild(fragment); 47 | return result; 48 | }; 49 | -------------------------------------------------------------------------------- /packages/src/utils/clipboard/data-transfer.ts: -------------------------------------------------------------------------------- 1 | import { getClipboardFromHTMLText } from './clipboard'; 2 | import { ClipboardData } from '../../types/clipboard'; 3 | 4 | export const setDataTransferClipboard = (dataTransfer: Pick | null, htmlText: string) => { 5 | dataTransfer?.setData(`text/html`, htmlText); 6 | }; 7 | 8 | export const setDataTransferClipboardText = (data: Pick | null, text: string) => { 9 | data?.setData(`text/plain`, text); 10 | }; 11 | 12 | export const getDataTransferClipboard = (data: Pick | null): ClipboardData => { 13 | const html = data?.getData(`text/html`); 14 | if (html) { 15 | const htmlClipboardData = getClipboardFromHTMLText(html); 16 | if (htmlClipboardData) { 17 | return htmlClipboardData; 18 | } 19 | const textData = getDataTransferClipboardText(data); 20 | if (textData) { 21 | return { 22 | html, 23 | ...textData 24 | }; 25 | } else { 26 | return { html }; 27 | } 28 | } else { 29 | const textData = getDataTransferClipboardText(data); 30 | return textData; 31 | } 32 | }; 33 | 34 | export const getDataTransferClipboardText = (data: Pick | null): ClipboardData => { 35 | if (!data) { 36 | return null; 37 | } 38 | const text = data?.getData(`text/plain`); 39 | if (text) { 40 | const htmlClipboardData = getClipboardFromHTMLText(text); 41 | if (htmlClipboardData) { 42 | return htmlClipboardData; 43 | } 44 | } 45 | return { text }; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/src/utils/clipboard/index.ts: -------------------------------------------------------------------------------- 1 | export * from './clipboard'; 2 | export * from './data-transfer'; 3 | export * from './navigator-clipboard'; 4 | export * from './common'; 5 | -------------------------------------------------------------------------------- /packages/src/utils/clipboard/navigator-clipboard.ts: -------------------------------------------------------------------------------- 1 | import { Element } from 'slate'; 2 | import { getClipboardFromHTMLText } from './clipboard'; 3 | import { blobAsString, isClipboardFile, isClipboardReadSupported, isClipboardWriteSupported, stripHtml } from './common'; 4 | import { ClipboardData } from '../../types/clipboard'; 5 | 6 | export const setNavigatorClipboard = async (htmlText: string, data: Element[], text: string = '') => { 7 | let textClipboard = text; 8 | if (isClipboardWriteSupported()) { 9 | await navigator.clipboard.write([ 10 | new ClipboardItem({ 11 | 'text/html': new Blob([htmlText], { 12 | type: 'text/html' 13 | }), 14 | 'text/plain': new Blob([textClipboard ?? JSON.stringify(data)], { type: 'text/plain' }) 15 | }) 16 | ]); 17 | } 18 | }; 19 | 20 | export const getNavigatorClipboard = async () => { 21 | if (!isClipboardReadSupported()) { 22 | return null; 23 | } 24 | const clipboardItems = await navigator.clipboard.read(); 25 | let clipboardData: ClipboardData = {}; 26 | 27 | if (Array.isArray(clipboardItems) && clipboardItems[0] instanceof ClipboardItem) { 28 | for (const item of clipboardItems) { 29 | if (isClipboardFile(item)) { 30 | const clipboardFiles = item.types.filter(type => type.match(/^image\//)); 31 | const fileBlobs = await Promise.all(clipboardFiles.map(type => item.getType(type)!)); 32 | const urls = (fileBlobs.filter(Boolean) as (File | Blob)[]).map(blob => URL.createObjectURL(blob)); 33 | const files = await Promise.all( 34 | urls.map(async url => { 35 | const blob = await (await fetch(url)).blob(); 36 | return new File([blob], 'file', { type: blob.type }); 37 | }) 38 | ); 39 | clipboardData = { 40 | ...clipboardData, 41 | files 42 | }; 43 | } 44 | if (item.types.includes('text/html')) { 45 | const htmlContent = await blobAsString(await item.getType('text/html')); 46 | const htmlClipboardData = getClipboardFromHTMLText(htmlContent); 47 | if (htmlClipboardData) { 48 | clipboardData = { ...clipboardData, ...htmlClipboardData }; 49 | return clipboardData; 50 | } 51 | if (htmlContent && htmlContent.trim()) { 52 | clipboardData = { ...clipboardData, html: htmlContent }; 53 | } 54 | } 55 | if (item.types.includes('text/plain')) { 56 | const textContent = await blobAsString(await item.getType('text/plain')); 57 | clipboardData = { 58 | ...clipboardData, 59 | text: stripHtml(textContent) 60 | }; 61 | } 62 | } 63 | } 64 | return clipboardData; 65 | }; 66 | -------------------------------------------------------------------------------- /packages/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const TRIPLE_CLICK = 3; 2 | -------------------------------------------------------------------------------- /packages/src/utils/environment.ts: -------------------------------------------------------------------------------- 1 | export const IS_IOS = 2 | typeof navigator !== 'undefined' && 3 | typeof window !== 'undefined' && 4 | /iPad|iPhone|iPod/.test(navigator.userAgent) && 5 | !(window as any).MSStream; 6 | 7 | export const IS_APPLE = typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent); 8 | 9 | export const IS_ANDROID = typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent); 10 | 11 | export const IS_FIREFOX = typeof navigator !== 'undefined' && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent); 12 | 13 | export const IS_SAFARI = typeof navigator !== 'undefined' && /Version\/[\d\.]+.*Safari/.test(navigator.userAgent); 14 | 15 | // "modern" Edge was released at 79.x 16 | export const IS_EDGE_LEGACY = typeof navigator !== 'undefined' && /Edge?\/(?:[0-6][0-9]|[0-7][0-8])(?:\.)/i.test(navigator.userAgent); 17 | 18 | export const IS_CHROME = typeof navigator !== 'undefined' && /Chrome/i.test(navigator.userAgent); 19 | 20 | // Native beforeInput events don't work well with react on Chrome 75 and older, Chrome 76+ can use beforeInput 21 | export const IS_CHROME_LEGACY = 22 | typeof navigator !== 'undefined' && 23 | /Chrome?\/(?:[0-7][0-5]|[0-6][0-9])/i.test(navigator.userAgent) && 24 | // Exclude Chrome version greater than 3 bits,Chrome releases v100 on 2022.03.29 25 | !/Chrome?\/(?:\d{3,})/i.test(navigator.userAgent); 26 | 27 | // Firefox did not support `beforeInput` until `v87`. 28 | export const IS_FIREFOX_LEGACY = 29 | typeof navigator !== 'undefined' && /^(?!.*Seamonkey)(?=.*Firefox\/(?:[0-7][0-9]|[0-8][0-6])(?:\.)).*/i.test(navigator.userAgent); 30 | 31 | // qq browser 32 | export const IS_QQBROWSER = typeof navigator !== 'undefined' && /.*QQBrowser/.test(navigator.userAgent); 33 | 34 | // UC mobile browser 35 | export const IS_UC_MOBILE = typeof navigator !== 'undefined' && /.*UCBrowser/.test(navigator.userAgent); 36 | 37 | // Wechat browser 38 | export const IS_WECHATBROWSER = typeof navigator !== 'undefined' && /.*Wechat/.test(navigator.userAgent); 39 | 40 | // COMPAT: Firefox/Edge Legacy don't support the `beforeinput` event 41 | // Chrome Legacy doesn't support `beforeinput` correctly 42 | export const HAS_BEFORE_INPUT_SUPPORT = 43 | !IS_CHROME_LEGACY && 44 | !IS_EDGE_LEGACY && 45 | // globalThis is undefined in older browsers 46 | typeof globalThis !== 'undefined' && 47 | globalThis.InputEvent && 48 | // @ts-ignore The `getTargetRanges` property isn't recognized. 49 | typeof globalThis.InputEvent.prototype.getTargetRanges === 'function'; 50 | -------------------------------------------------------------------------------- /packages/src/utils/global-normalize.spec.ts: -------------------------------------------------------------------------------- 1 | import { Element } from 'slate'; 2 | import { check, normalize } from './global-normalize'; 3 | 4 | describe('global-normalize', () => { 5 | const invalidData3: any[] = [ 6 | { 7 | type: 'paragraph', 8 | children: [{ text: '' }] 9 | }, 10 | { 11 | type: 'numbered-list', 12 | children: [ 13 | { 14 | type: 'list-item', 15 | children: [ 16 | { 17 | type: 'paragraph', 18 | children: [ 19 | { 20 | text: '' 21 | } 22 | ] 23 | }, 24 | { 25 | type: 'paragraph', 26 | children: [] 27 | } 28 | ] 29 | } 30 | ] 31 | } 32 | ]; 33 | 34 | it('should return true', () => { 35 | const validData: Element[] = [ 36 | { 37 | type: 'paragraph', 38 | children: [ 39 | { text: 'This is editable ' }, 40 | { text: 'rich', bold: true }, 41 | { text: ' text, ' }, 42 | { text: 'much', bold: true, italic: true }, 43 | { text: ' better than a ' }, 44 | { text: '