├── packages ├── README.md ├── src │ ├── types │ │ ├── feature.ts │ │ ├── index.ts │ │ ├── clipboard.ts │ │ ├── view.ts │ │ ├── editable.ts │ │ └── error.ts │ ├── testing │ │ ├── types.ts │ │ ├── index.ts │ │ ├── module.ts │ │ ├── element-focus.ts │ │ ├── basic-editable.component.ts │ │ ├── leaf.flavour.ts │ │ ├── create-document.ts │ │ ├── image-editable.component.ts │ │ ├── dispatcher-events.ts │ │ ├── editable-with-outlet.component.ts │ │ ├── advanced-editable.component.ts │ │ └── events.ts │ ├── view │ │ ├── flavour │ │ │ ├── index.ts │ │ │ ├── ref.ts │ │ │ ├── base.ts │ │ │ ├── text.ts │ │ │ ├── leaf.ts │ │ │ └── element.ts │ │ ├── types.ts │ │ ├── context-change.ts │ │ ├── context.ts │ │ └── render │ │ │ ├── utils.ts │ │ │ └── list-render.spec.ts │ ├── utils │ │ ├── clipboard │ │ │ ├── index.ts │ │ │ ├── data-transfer.ts │ │ │ ├── common.ts │ │ │ ├── navigator-clipboard.ts │ │ │ └── clipboard.ts │ │ ├── index.ts │ │ ├── throttle.ts │ │ ├── view.ts │ │ ├── global-normalize.ts │ │ ├── weak-maps.ts │ │ ├── restore-dom.ts │ │ ├── block-card.ts │ │ ├── range-list.ts │ │ ├── environment.ts │ │ ├── dom.ts │ │ ├── hotkeys.ts │ │ └── global-normalize.spec.ts │ ├── module.ts │ ├── components │ │ ├── block-card │ │ │ ├── block-card.component.scss │ │ │ ├── block-card.component.spec.ts │ │ │ └── block-card.ts │ │ ├── children │ │ │ └── children-outlet.component.ts │ │ ├── element.flavour.ts │ │ ├── leaf │ │ │ └── leaf.flavour.ts │ │ ├── text │ │ │ └── default-text.flavour.ts │ │ └── string │ │ │ └── slate-string.spec.ts │ ├── test.ts │ ├── custom-event │ │ ├── DOMTopLevelEventTypes.ts │ │ ├── before-input-polyfill.ts │ │ └── FallbackCompositionState.ts │ ├── public-api.ts │ ├── styles │ │ └── index.scss │ └── plugins │ │ ├── angular-editor.spec.ts │ │ └── angular-editor.ts ├── tsconfig.spec.json ├── tsconfig.lib.prod.json ├── ng-package.json ├── tsconfig.lib.json ├── package.json └── karma.conf.js ├── demo ├── app │ ├── embeds │ │ ├── embeds.component.scss │ │ ├── embeds.component.html │ │ └── embeds.component.ts │ ├── components │ │ ├── video │ │ │ ├── video.component.scss │ │ │ ├── video.component.html │ │ │ └── video.component.ts │ │ ├── image │ │ │ └── image-component.ts │ │ ├── editable-void │ │ │ ├── editable-void.component.scss │ │ │ ├── editable-void.component.html │ │ │ └── editable-void.component.ts │ │ ├── button │ │ │ └── button.component.ts │ │ ├── editable-button │ │ │ └── editable-button.component.ts │ │ └── link │ │ │ └── link.component.ts │ ├── editable-voids │ │ ├── editable-voids.component.scss │ │ ├── editable-voids.component.html │ │ └── editable-voids.component.ts │ ├── flavours │ │ ├── quote.flavour.ts │ │ ├── base.flavour.ts │ │ ├── list.flavour.ts │ │ ├── richtext.flavour.ts │ │ └── heading.flavour.ts │ ├── markdown-shorcuts │ │ └── markdown-shortcuts.component.html │ ├── tables │ │ ├── tables.component.html │ │ └── table.flavour.ts │ ├── search-highlighting │ │ ├── search-highlighting.component.scss │ │ ├── search-highlighting.component.html │ │ ├── hightlighting-leaf.flavour.ts │ │ └── search-highlighting.component.ts │ ├── images │ │ ├── images.component.html │ │ └── images.component.ts │ ├── mentions │ │ ├── mentions.component.html │ │ ├── mention.flavour.ts │ │ └── mentions.component.scss │ ├── app.component.spec.ts │ ├── inlines │ │ └── inlines.component.html │ ├── richtext │ │ └── richtext.component.html │ ├── readonly │ │ └── readonly.component.ts │ ├── app.component.html │ ├── huge-document │ │ ├── huge-document.component.html │ │ └── huge-document.component.ts │ ├── app.component.ts │ ├── app-routing.module.ts │ ├── placeholder │ │ └── placeholder.component.ts │ ├── app.module.ts │ └── plugins │ │ └── block-cards.plugin.ts ├── favicon.ico ├── assets │ ├── photo.jpeg │ └── materialicons │ │ ├── flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2 │ │ └── icon.css ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── tsconfig.app.json ├── tsconfig.spec.json ├── index.html ├── main.ts ├── styles.scss ├── test.ts ├── editor-typo.scss └── polyfills.ts ├── .coveralls.yml ├── .changeset ├── bright-buses-wish.md ├── loud-drinks-wash.md ├── puny-apples-turn.md ├── bumpy-cars-jump.md ├── funny-vans-exist.md ├── fine-tools-accept.md ├── fluffy-windows-worry.md ├── slimy-laws-clean.md ├── sweet-aliens-throw.md ├── thin-yaks-prove.md ├── young-adults-carry.md ├── cyan-walls-see.md ├── heavy-icons-share.md ├── orange-windows-visit.md ├── yummy-cloths-begin.md ├── happy-jobs-return.md ├── fine-mails-write.md ├── warm-fans-move.md ├── itchy-lands-allow.md ├── thirty-crews-wear.md ├── witty-horses-fix.md ├── odd-oranges-dig.md ├── curly-coats-wash.md ├── dirty-pens-begin.md ├── tangy-plants-itch.md ├── config.json ├── README.md └── pre.json ├── docs ├── images │ └── banner.jpeg └── compatible.md ├── Dockerfile ├── .prettierrc ├── e2e ├── tsconfig.json ├── src │ ├── app.po.ts │ └── app.e2e-spec.ts └── protractor.conf.js ├── tsconfig.app.json ├── tsconfig.spec.json ├── .editorconfig ├── nginx.conf ├── commitlint.config.js ├── .gitignore ├── .circleci └── config.yml ├── tsconfig.json ├── LICENSE ├── .eslintrc.json ├── karma.conf.js ├── custom-types.d.ts ├── types └── custom-types.d.ts └── package.json /packages/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/embeds/embeds.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/app/components/video/video.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: 5MUHe1rNtCfuUbqtr7ebinZSzCpYGc5nq 2 | -------------------------------------------------------------------------------- /demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/HEAD/demo/favicon.ico -------------------------------------------------------------------------------- /demo/assets/photo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/HEAD/demo/assets/photo.jpeg -------------------------------------------------------------------------------- /demo/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /.changeset/bright-buses-wish.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | optimize virtual logs 6 | -------------------------------------------------------------------------------- /.changeset/loud-drinks-wash.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | rename virtual-scroll 6 | -------------------------------------------------------------------------------- /.changeset/puny-apples-turn.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | fix first loading issue 6 | -------------------------------------------------------------------------------- /docs/images/banner.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/HEAD/docs/images/banner.jpeg -------------------------------------------------------------------------------- /.changeset/bumpy-cars-jump.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | fix(virtual): fix scrolling lag 6 | -------------------------------------------------------------------------------- /.changeset/funny-vans-exist.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': minor 3 | --- 4 | 5 | set up virtual scrolling structure 6 | -------------------------------------------------------------------------------- /.changeset/fine-tools-accept.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': minor 3 | --- 4 | 5 | support isVisible in withAngular plugin 6 | -------------------------------------------------------------------------------- /.changeset/fluffy-windows-worry.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': minor 3 | --- 4 | 5 | support selection in visible range 6 | -------------------------------------------------------------------------------- /.changeset/slimy-laws-clean.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | use getBoundingClientRect to get height 6 | -------------------------------------------------------------------------------- /.changeset/sweet-aliens-throw.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': minor 3 | --- 4 | 5 | fix text flavour can not destroy issue 6 | -------------------------------------------------------------------------------- /.changeset/thin-yaks-prove.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | diff scrolling optimization, adding logs 6 | -------------------------------------------------------------------------------- /.changeset/young-adults-carry.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | support isEnabledVirtualScroll status 6 | -------------------------------------------------------------------------------- /.changeset/cyan-walls-see.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | strategy for getting realHeight when scrolling 6 | -------------------------------------------------------------------------------- /.changeset/heavy-icons-share.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | add element keys to measure height of wekmap 6 | -------------------------------------------------------------------------------- /.changeset/orange-windows-visit.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | support remeasure heights on data change 6 | -------------------------------------------------------------------------------- /.changeset/yummy-cloths-begin.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': minor 3 | --- 4 | 5 | correct visible elements calculation logic 6 | -------------------------------------------------------------------------------- /.changeset/happy-jobs-return.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | move marginTop and marginBottom to getRealHeight 6 | -------------------------------------------------------------------------------- /.changeset/fine-mails-write.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | calculate real height base on block card or native element 6 | -------------------------------------------------------------------------------- /.changeset/warm-fans-move.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | get measured height strengthen the judgment of numeric types 6 | -------------------------------------------------------------------------------- /.changeset/itchy-lands-allow.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | fix select in backward scenario and selection is null scenario 6 | -------------------------------------------------------------------------------- /demo/app/editable-voids/editable-voids.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | [data-slate-editor] > * + * { 3 | margin-top: 1em; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.changeset/thirty-crews-wear.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | fix(virtual): width change remeasurement height, add debug overlay 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.changeset/witty-horses-fix.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | there is a blank space below the scroll when the page switches to widescreen 6 | -------------------------------------------------------------------------------- /.changeset/odd-oranges-dig.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': minor 3 | --- 4 | 5 | prevent executing virtual scroll logic when the editor is not enabled virtual scroll 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "arrowParens": "avoid", 5 | "trailingComma": "none", 6 | "printWidth": 140 7 | } 8 | -------------------------------------------------------------------------------- /packages/src/types/feature.ts: -------------------------------------------------------------------------------- 1 | import { BaseRange } from 'slate'; 2 | 3 | export interface SlatePlaceholder extends BaseRange { 4 | placeholder: string; 5 | } 6 | -------------------------------------------------------------------------------- /.changeset/curly-coats-wash.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | optmize virtual logs,diff needs to determine whether it is consistent before and after 6 | -------------------------------------------------------------------------------- /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/view/flavour/index.ts: -------------------------------------------------------------------------------- 1 | export * from './element'; 2 | export * from './leaf'; 3 | export * from './text'; 4 | export * from './base'; 5 | export * from './ref'; 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/assets/materialicons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worktile/slate-angular/HEAD/demo/assets/materialicons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.changeset/dirty-pens-begin.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': minor 3 | --- 4 | 5 | getRealHeight support both number and promise return type 6 | 7 | remeasureHeightByIndics changed to sync method and improve logs 8 | -------------------------------------------------------------------------------- /.changeset/tangy-plants-itch.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'slate-angular': patch 3 | --- 4 | 5 | 1. Add top and bottom virtual height elements 6 | 2. Add JUST_NOW_UPDATED_VIRTUAL_VIEW to avoid circle trigger virtual scrolling 7 | -------------------------------------------------------------------------------- /packages/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error'; 2 | export * from './view'; 3 | export * from './feature'; 4 | export * from './clipboard'; 5 | export * from './editable'; 6 | 7 | export type SafeAny = any; 8 | -------------------------------------------------------------------------------- /packages/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": ["jasmine"] 6 | }, 7 | "include": ["**/*.ts", "**/*.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["demo/main.ts", "demo/polyfills.ts"], 8 | "include": ["demo/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/app/flavours/quote.flavour.ts: -------------------------------------------------------------------------------- 1 | import { BaseFlavour } from './base.flavour'; 2 | 3 | export class BlockquoteFlavour extends BaseFlavour { 4 | createNativeElement(): HTMLElement { 5 | const element = document.createElement('blockquote'); 6 | return element; 7 | } 8 | } -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["demo/test.ts", "demo/polyfills.ts"], 8 | "include": ["demo/**/*.spec.ts", "demo/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/src/types/view.ts: -------------------------------------------------------------------------------- 1 | import { TemplateRef } from '@angular/core'; 2 | import { BaseFlavour } from '../view/flavour/base'; 3 | 4 | export interface ComponentType { 5 | new (...args: any[]): T; 6 | } 7 | 8 | export type ViewType = TemplateRef | ComponentType | BaseFlavour; 9 | -------------------------------------------------------------------------------- /packages/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/app/embeds/embeds.component.html: -------------------------------------------------------------------------------- 1 |
2 | 9 | 10 |
11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/src/view/types.ts: -------------------------------------------------------------------------------- 1 | import { AngularEditor } from '../plugins/angular-editor'; 2 | import { SlateViewContext } from './context'; 3 | 4 | /** 5 | * base class for template 6 | */ 7 | export interface BaseEmbeddedView { 8 | context: T; 9 | viewContext: SlateViewContext; 10 | } 11 | -------------------------------------------------------------------------------- /demo/app/markdown-shorcuts/markdown-shortcuts.component.html: -------------------------------------------------------------------------------- 1 |
2 | 9 | 10 |
11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './weak-maps'; 2 | export * from './hotkeys'; 3 | export * from './view'; 4 | export * from './environment'; 5 | export * from './range-list'; 6 | export * from './block-card'; 7 | export * from './global-normalize'; 8 | export * from './throttle'; 9 | export * from './clipboard'; 10 | export * from './dom'; 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /demo/app/tables/tables.component.html: -------------------------------------------------------------------------------- 1 |
2 | 11 | 12 |
13 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /packages/src/view/flavour/ref.ts: -------------------------------------------------------------------------------- 1 | import { SlateBlockCard } from '../../components/block-card/block-card'; 2 | import { BaseFlavour } from './base'; 3 | 4 | export class FlavourRef { 5 | instance: BaseFlavour; 6 | 7 | destroy(): void { 8 | this.instance.onDestroy(); 9 | } 10 | } 11 | 12 | export class BlockCardRef { 13 | instance: SlateBlockCard; 14 | 15 | destroy(): void { 16 | this.instance.onDestroy(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/src/types/editable.ts: -------------------------------------------------------------------------------- 1 | import { Element } from 'slate'; 2 | 3 | export interface SlateVirtualScrollConfig { 4 | enabled?: boolean; 5 | scrollTop: number; 6 | viewportHeight: number; 7 | blockHeight?: number; 8 | bufferCount?: number; 9 | } 10 | 11 | export interface VirtualViewResult { 12 | inViewportChildren: Element[]; 13 | visibleIndexes: Set; 14 | top: number; 15 | bottom: number; 16 | heights: number[]; 17 | } 18 | -------------------------------------------------------------------------------- /packages/src/module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { SlateEditable } from './components/editable/editable.component'; 4 | import { SlateChildrenOutlet } from './components/children/children-outlet.component'; 5 | 6 | @NgModule({ 7 | imports: [CommonModule, SlateEditable, SlateChildrenOutlet], 8 | exports: [SlateEditable, SlateChildrenOutlet], 9 | providers: [] 10 | }) 11 | export class SlateModule {} 12 | -------------------------------------------------------------------------------- /demo/app/flavours/base.flavour.ts: -------------------------------------------------------------------------------- 1 | import { AngularEditor, BaseElementFlavour } from 'slate-angular'; 2 | import { Element } from 'slate'; 3 | 4 | export abstract class BaseFlavour extends BaseElementFlavour { 5 | abstract createNativeElement(): HTMLElement; 6 | 7 | render() { 8 | this.nativeElement = this.createNativeElement(); 9 | } 10 | 11 | rerender() { 12 | // No-op 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /demo/app/components/video/video.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 | 12 |
13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/lib", 5 | "declarationMap": true, 6 | "declaration": true, 7 | "inlineSources": true, 8 | "types": [], 9 | "lib": ["dom", "dom.iterable", "es2018"] 10 | }, 11 | "angularCompilerOptions": { 12 | "skipTemplateCodegen": true, 13 | "strictMetadataEmit": true, 14 | "enableResourceInlining": true 15 | }, 16 | "exclude": ["src/test.ts", "**/*.spec.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/app/images/images.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | image 5 | 6 |
7 | 14 | 15 |
16 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/src/components/element.flavour.ts: -------------------------------------------------------------------------------- 1 | import { BaseElementFlavour } from "../view/flavour/element"; 2 | import { AngularEditor } from "../plugins/angular-editor"; 3 | import { Element } from "slate"; 4 | 5 | export class DefaultElementFlavour extends BaseElementFlavour { 6 | render() { 7 | const nativeElement = document.createElement('div'); 8 | this.nativeElement = nativeElement; 9 | } 10 | 11 | rerender() { 12 | // No-op 13 | } 14 | } -------------------------------------------------------------------------------- /demo/app/tables/table.flavour.ts: -------------------------------------------------------------------------------- 1 | import { BaseFlavour } from '../flavours/base.flavour'; 2 | 3 | export class TableFlavour extends BaseFlavour { 4 | createNativeElement(): HTMLElement { 5 | if (this.element.type === 'table') { 6 | return document.createElement('table'); 7 | } 8 | if (this.element.type === 'table-row') { 9 | return document.createElement('tr'); 10 | } 11 | if (this.element.type === 'table-cell') { 12 | return document.createElement('td'); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /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/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/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/view/context-change.ts: -------------------------------------------------------------------------------- 1 | export interface BeforeContextChange { 2 | beforeContextChange: (value: T) => void; 3 | } 4 | 5 | export interface AfterContextChange<> { 6 | afterContextChange: () => void; 7 | } 8 | 9 | export function hasBeforeContextChange(value): value is BeforeContextChange { 10 | if (value.beforeContextChange) { 11 | return true; 12 | } 13 | return false; 14 | } 15 | 16 | export function hasAfterContextChange(value): value is AfterContextChange { 17 | if (value.afterContextChange) { 18 | return true; 19 | } 20 | return false; 21 | } 22 | -------------------------------------------------------------------------------- /packages/src/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | export const createThrottleRAF = () => { 2 | let timerId: number | null = null; 3 | const throttleRAF = (fn: () => void) => { 4 | const scheduleFunc = () => { 5 | timerId = requestAnimationFrame(() => { 6 | timerId = null; 7 | fn(); 8 | }); 9 | }; 10 | if (timerId !== null) { 11 | cancelAnimationFrame(timerId); 12 | timerId = null; 13 | } 14 | scheduleFunc(); 15 | }; 16 | return throttleRAF; 17 | }; 18 | 19 | export type ThrottleRAF = (fn: () => void) => void; 20 | -------------------------------------------------------------------------------- /packages/src/utils/view.ts: -------------------------------------------------------------------------------- 1 | import { TemplateRef } from '@angular/core'; 2 | import { ComponentType, ViewType } from '../types/view'; 3 | import { BaseFlavour } from '../view/flavour/base'; 4 | 5 | export function isTemplateRef(value: ViewType): value is TemplateRef { 6 | return value && value instanceof TemplateRef; 7 | } 8 | 9 | export function isComponentType(value: ViewType): value is ComponentType { 10 | return !isTemplateRef(value); 11 | } 12 | 13 | export function isFlavourType(value: ViewType): value is ComponentType { 14 | return value && (value as any).isFlavour === true; 15 | } 16 | 17 | -------------------------------------------------------------------------------- /packages/src/utils/global-normalize.ts: -------------------------------------------------------------------------------- 1 | import { Descendant, Element, Text } from 'slate'; 2 | 3 | const isValid = (value: Descendant) => 4 | (Element.isElement(value) && value.children.length > 0 && (value.children as Descendant[]).every(child => isValid(child))) || 5 | Text.isText(value); 6 | 7 | const check = (document: Element[]) => { 8 | return document.every(value => Element.isElement(value) && isValid(value)); 9 | }; 10 | 11 | function normalize(document: Element[]) { 12 | return document.filter(value => Element.isElement(value) && isValid(value)); 13 | } 14 | 15 | export { normalize, check, isValid }; 16 | -------------------------------------------------------------------------------- /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/mentions/mentions.component.html: -------------------------------------------------------------------------------- 1 |
2 | 10 | 11 |
12 | @for (item of suggestions; track $index; let i = $index) { 13 |
14 | {{ item }} 15 |
16 | } 17 |
18 |
19 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/children/children-outlet.component'; 8 | export * from './components/text/default-text.flavour'; 9 | export * from './components/block-card/block-card'; 10 | export * from './module'; 11 | export * from './types/error'; 12 | export * from './view/base'; 13 | export * from './view/context'; 14 | export * from './view/flavour'; 15 | export * from './view/context-change'; 16 | export * from './utils'; 17 | export * from './types'; -------------------------------------------------------------------------------- /demo/app/flavours/list.flavour.ts: -------------------------------------------------------------------------------- 1 | import { BaseFlavour } from './base.flavour'; 2 | 3 | export class ULFlavour extends BaseFlavour { 4 | createNativeElement(): HTMLElement { 5 | const element = document.createElement('ul'); 6 | return element; 7 | } 8 | } 9 | 10 | export class OLFlavour extends BaseFlavour { 11 | createNativeElement(): HTMLElement { 12 | const element = document.createElement('ol'); 13 | return element; 14 | } 15 | } 16 | 17 | export class LIFlavour extends BaseFlavour { 18 | createNativeElement(): HTMLElement { 19 | const element = document.createElement('li'); 20 | return element; 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/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/inlines/inlines.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | @for (toolbarItem of toolbarItems; track $index) { 4 | {{ toolbarItem.icon }} 7 | } 8 |
9 | 17 | 18 |
19 | -------------------------------------------------------------------------------- /packages/src/utils/weak-maps.ts: -------------------------------------------------------------------------------- 1 | import { Node, Editor } from 'slate'; 2 | import { BaseElementComponent } from '../view/base'; 3 | import { BaseFlavour } from '../view/flavour/base'; 4 | 5 | /** 6 | * Symbols. 7 | */ 8 | 9 | export const PLACEHOLDER_SYMBOL = Symbol('placeholder') as unknown as string; 10 | 11 | /** 12 | * Weak map for associating the html element with the component. 13 | */ 14 | export const ELEMENT_TO_COMPONENT: WeakMap = new WeakMap(); 15 | 16 | export const IS_ENABLED_VIRTUAL_SCROLL: WeakMap = new WeakMap(); 17 | 18 | export const EDITOR_TO_AFTER_VIEW_INIT_QUEUE: WeakMap void)[]> = new WeakMap(); 19 | -------------------------------------------------------------------------------- /demo/app/search-highlighting/hightlighting-leaf.flavour.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, Renderer2 } from '@angular/core'; 2 | import { BaseLeafComponent } from 'slate-angular'; 3 | 4 | @Component({ 5 | selector: 'span[demoLeaf]', 6 | template: ``, 7 | imports: [] 8 | }) 9 | export class DemoLeafComponent extends BaseLeafComponent { 10 | private renderer = inject(Renderer2); 11 | 12 | onContextChange() { 13 | super.onContextChange(); 14 | this.changeStyle(); 15 | } 16 | 17 | changeStyle() { 18 | const backgroundColor = this.leaf['highlight'] ? '#ffeeba' : null; 19 | this.renderer.setStyle(this.nativeElement, 'backgroundColor', backgroundColor); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/app/mentions/mention.flavour.ts: -------------------------------------------------------------------------------- 1 | import { MentionElement } from "../../../custom-types"; 2 | import { BaseFlavour } from "../flavours/base.flavour"; 3 | 4 | export class MentionFlavour extends BaseFlavour { 5 | createNativeElement(): HTMLElement { 6 | const element = document.createElement('span'); 7 | element.classList.add('demo-mention-view'); 8 | element.textContent = `@${this.context.element.character}`; 9 | return element; 10 | } 11 | 12 | rerender(): void { 13 | super.rerender(); 14 | if (this.context.selection) { 15 | this.nativeElement.classList.add('focus') 16 | } else { 17 | this.nativeElement.classList.remove('focus') 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /demo/app/richtext/richtext.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | @for (toolbarItem of toolbarItems; track $index) { 4 | {{ toolbarItem.icon }} 7 | } 8 |
9 | 19 | 20 |
21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.changeset/pre.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "pre", 3 | "tag": "next", 4 | "initialVersions": { 5 | "slate-angular": "20.1.0" 6 | }, 7 | "changesets": [ 8 | "bright-buses-wish", 9 | "bumpy-cars-jump", 10 | "curly-coats-wash", 11 | "cyan-walls-see", 12 | "dirty-pens-begin", 13 | "fine-mails-write", 14 | "fine-tools-accept", 15 | "fluffy-windows-worry", 16 | "funny-vans-exist", 17 | "happy-jobs-return", 18 | "heavy-icons-share", 19 | "itchy-lands-allow", 20 | "loud-drinks-wash", 21 | "odd-oranges-dig", 22 | "orange-windows-visit", 23 | "puny-apples-turn", 24 | "slimy-laws-clean", 25 | "sweet-aliens-throw", 26 | "tangy-plants-itch", 27 | "thin-yaks-prove", 28 | "thirty-crews-wear", 29 | "warm-fans-move", 30 | "witty-horses-fix", 31 | "young-adults-carry", 32 | "yummy-cloths-begin" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /packages/src/components/leaf/leaf.flavour.ts: -------------------------------------------------------------------------------- 1 | import { BaseLeafFlavour } from '../../view/flavour/leaf'; 2 | import { SlateStringRender } from '../string/string-render'; 3 | 4 | export class DefaultLeafFlavour extends BaseLeafFlavour { 5 | stringRender: SlateStringRender | null = null; 6 | 7 | render() { 8 | const leafNode = createDefaultLeafNode(); 9 | this.stringRender = new SlateStringRender(this.context, this.viewContext); 10 | const stringNode = this.stringRender.render(); 11 | leafNode.appendChild(stringNode); 12 | this.nativeElement = leafNode; 13 | } 14 | 15 | rerender() { 16 | this.stringRender?.update(this.context, this.viewContext); 17 | } 18 | } 19 | 20 | export const createDefaultLeafNode = () => { 21 | const span = document.createElement('span'); 22 | span.setAttribute('data-slate-leaf', 'true'); 23 | return span; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/src/components/text/default-text.flavour.ts: -------------------------------------------------------------------------------- 1 | import { BaseTextFlavour } from '../../view/flavour/text'; 2 | 3 | export class DefaultTextFlavour extends BaseTextFlavour { 4 | render() { 5 | const { nativeElement } = createText(this.text.text); 6 | this.nativeElement = nativeElement; 7 | } 8 | rerender() {} 9 | } 10 | 11 | export class VoidTextFlavour extends BaseTextFlavour { 12 | render() { 13 | const { nativeElement } = createText(this.text.text); 14 | this.nativeElement = nativeElement; 15 | this.nativeElement.setAttribute('data-slate-spacer', 'true'); 16 | this.nativeElement.classList.add('slate-spacer'); 17 | } 18 | rerender() {} 19 | } 20 | 21 | export const createText = (text: string) => { 22 | const nativeElement = document.createElement('span'); 23 | nativeElement.setAttribute('data-slate-node', 'text'); 24 | return { nativeElement }; 25 | }; 26 | -------------------------------------------------------------------------------- /.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:22.13.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 | -------------------------------------------------------------------------------- /packages/src/testing/leaf.flavour.ts: -------------------------------------------------------------------------------- 1 | import { SlateStringRender } from '../components/string/string-render'; 2 | import { BaseLeafFlavour } from '../view/flavour/leaf'; 3 | 4 | export class TestingLeafFlavour extends BaseLeafFlavour { 5 | stringRender: SlateStringRender | null = null; 6 | 7 | render() { 8 | const leafNode = createDefaultLeafNode(); 9 | this.stringRender = new SlateStringRender(this.context, this.viewContext); 10 | const stringNode = this.stringRender.render(); 11 | leafNode.appendChild(stringNode); 12 | this.nativeElement = leafNode; 13 | this.nativeElement.classList.add('testing-leaf'); 14 | } 15 | 16 | rerender() { 17 | this.stringRender?.update(this.context, this.viewContext); 18 | } 19 | } 20 | 21 | export const createDefaultLeafNode = () => { 22 | const span = document.createElement('span'); 23 | span.setAttribute('data-slate-leaf', 'true'); 24 | return span; 25 | }; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "module": "es2020", 11 | "moduleResolution": "bundler", 12 | "importHelpers": true, 13 | "target": "ES2022", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2020", 19 | "dom.iterable", 20 | "dom" 21 | ], 22 | "resolveJsonModule": true, 23 | "paths": { 24 | "slate-angular": [ 25 | "packages/src/public-api" 26 | ], 27 | "slate-angular/*": [ 28 | "packages/src/*" 29 | ] 30 | }, 31 | "useDefineForClassFields": false 32 | }, 33 | "angularCompilerOptions": { 34 | "fullTemplateTypeCheck": true, 35 | "strictTemplates": true, 36 | "strictInjectionParameters": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slate-angular", 3 | "version": "20.2.0-next.15", 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.117.2", 10 | "slate-dom": "^0.116.0", 11 | "slate-history": "^0.115.0", 12 | "debug": "^4.1.1", 13 | "direction": "^2.0.1", 14 | "is-hotkey": "^0.2.0", 15 | "scroll-into-view-if-needed": "^2.2.20" 16 | }, 17 | "dependencies": { 18 | "slate": "^0.117.2", 19 | "slate-dom": "^0.116.0", 20 | "slate-history": "^0.115.0", 21 | "direction": "^2.0.1", 22 | "is-hotkey": "^0.2.0", 23 | "scroll-into-view-if-needed": "^3.1.0", 24 | "tslib": "^2.6.2" 25 | }, 26 | "exports": { 27 | ".": { 28 | "sass": "./styles/index.scss" 29 | }, 30 | "./styles": { 31 | "sass": "./styles/index.scss" 32 | }, 33 | "./styles/index.scss": { 34 | "sass": "./styles/index.scss" 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /packages/src/components/block-card/block-card.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; 2 | import { ImageEditableComponent, configureBasicEditableTestingModule } from '../../testing'; 3 | import { SLATE_BLOCK_CARD_CLASS_NAME } from './block-card'; 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.nativeNode as HTMLElement).querySelector(`.${SLATE_BLOCK_CARD_CLASS_NAME}`); 21 | expect(blockCardElement).toBeTruthy(); 22 | })); 23 | }); 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/app/flavours/richtext.flavour.ts: -------------------------------------------------------------------------------- 1 | import { DefaultTextFlavour } from 'slate-angular'; 2 | 3 | export enum MarkTypes { 4 | bold = 'bold', 5 | italic = 'italic', 6 | underline = 'underlined', 7 | strike = 'strike', 8 | code = 'code-line' 9 | } 10 | 11 | export class RichTextFlavour extends DefaultTextFlavour { 12 | attributes = []; 13 | 14 | render() { 15 | super.render(); 16 | this.applyRichtext(); 17 | } 18 | 19 | applyRichtext() { 20 | this.attributes.forEach(attr => { 21 | this.nativeElement.removeAttribute(attr); 22 | }); 23 | this.attributes = []; 24 | for (const key in this.text) { 25 | if (Object.prototype.hasOwnProperty.call(this.text, key) && key !== 'text' && !!this.text[key]) { 26 | const attr = `slate-${key}`; 27 | this.nativeElement.setAttribute(attr, 'true'); 28 | this.attributes.push(attr); 29 | } 30 | } 31 | } 32 | 33 | onContextChange() { 34 | super.onContextChange(); 35 | if (this.initialized) { 36 | this.applyRichtext(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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: 0 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 | -------------------------------------------------------------------------------- /demo/app/flavours/heading.flavour.ts: -------------------------------------------------------------------------------- 1 | import { BaseFlavour } from './base.flavour'; 2 | 3 | export class H1Flavour extends BaseFlavour { 4 | createNativeElement(): HTMLElement { 5 | const element = document.createElement('h1'); 6 | return element; 7 | } 8 | } 9 | 10 | export class H2Flavour extends BaseFlavour { 11 | createNativeElement(): HTMLElement { 12 | const element = document.createElement('h2'); 13 | return element; 14 | } 15 | } 16 | 17 | export class H3Flavour extends BaseFlavour { 18 | createNativeElement(): HTMLElement { 19 | const element = document.createElement('h3'); 20 | return element; 21 | } 22 | } 23 | 24 | export class H4Flavour extends BaseFlavour { 25 | createNativeElement(): HTMLElement { 26 | const element = document.createElement('h4'); 27 | return element; 28 | } 29 | } 30 | 31 | export class H5Flavour extends BaseFlavour { 32 | createNativeElement(): HTMLElement { 33 | const element = document.createElement('h5'); 34 | return element; 35 | } 36 | } 37 | 38 | export class H6Flavour extends BaseFlavour { 39 | createNativeElement(): HTMLElement { 40 | const element = document.createElement('h6'); 41 | return element; 42 | } 43 | } -------------------------------------------------------------------------------- /demo/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 8 | 12 |
13 |
14 | menu 15 | {{ activeNav?.name }} 16 |
17 |
18 | 32 |
33 |
34 | 35 |
36 | -------------------------------------------------------------------------------- /packages/src/utils/restore-dom.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from 'slate'; 2 | import { EDITOR_TO_ELEMENT } from 'slate-dom'; 3 | 4 | export function restoreDom(editor: Editor, execute: () => void) { 5 | const editable = EDITOR_TO_ELEMENT.get(editor); 6 | let observer = new MutationObserver(mutations => { 7 | mutations.reverse().forEach(mutation => { 8 | if (mutation.type === 'characterData') { 9 | // We don't want to restore the DOM for characterData mutations 10 | // because this interrupts the composition. 11 | return; 12 | } 13 | 14 | mutation.removedNodes.forEach(node => { 15 | mutation.target.insertBefore(node, mutation.nextSibling); 16 | }); 17 | 18 | mutation.addedNodes.forEach(node => { 19 | mutation.target.removeChild(node); 20 | }); 21 | }); 22 | disconnect(); 23 | execute(); 24 | }); 25 | const disconnect = () => { 26 | observer.disconnect(); 27 | observer = null; 28 | }; 29 | observer.observe(editable, { subtree: true, childList: true, characterData: true, characterDataOldValue: true }); 30 | setTimeout(() => { 31 | if (observer) { 32 | disconnect(); 33 | execute(); 34 | } 35 | }, 0); 36 | } 37 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/src/utils/block-card.ts: -------------------------------------------------------------------------------- 1 | import { DOMNode, DOMSelection } from "slate-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/view/flavour/base.ts: -------------------------------------------------------------------------------- 1 | import { ViewContainerRef } from '@angular/core'; 2 | import { AngularEditor } from '../../plugins/angular-editor'; 3 | import { SlateElementContext, SlateLeafContext, SlateTextContext, SlateViewContext } from '../context'; 4 | import { hasAfterContextChange, hasBeforeContextChange } from '../context-change'; 5 | 6 | export abstract class BaseFlavour { 7 | static isFlavour = true; 8 | 9 | initialized = false; 10 | 11 | protected _context: T; 12 | 13 | viewContainerRef: ViewContainerRef; 14 | 15 | set context(value: T) { 16 | if (hasBeforeContextChange(this)) { 17 | this.beforeContextChange(value); 18 | } 19 | this._context = value; 20 | this.onContextChange(); 21 | if (hasAfterContextChange(this)) { 22 | this.afterContextChange(); 23 | } 24 | } 25 | 26 | get context() { 27 | return this._context; 28 | } 29 | 30 | viewContext: SlateViewContext; 31 | 32 | get editor() { 33 | return this.viewContext && this.viewContext.editor; 34 | } 35 | 36 | nativeElement: HTMLElement; 37 | 38 | abstract onContextChange(); 39 | 40 | abstract onInit(); 41 | 42 | abstract onDestroy(); 43 | 44 | abstract render(); 45 | 46 | abstract rerender(); 47 | } 48 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/src/utils/range-list.ts: -------------------------------------------------------------------------------- 1 | import { Range } from 'slate'; 2 | import { PLACEHOLDER_SYMBOL } from './weak-maps'; 3 | 4 | export const shallowCompare = (obj1: {}, obj2: {}) => 5 | Object.keys(obj1).length === Object.keys(obj2).length && 6 | Object.keys(obj1).every(key => obj2.hasOwnProperty(key) && obj1[key] === obj2[key]); 7 | 8 | /** 9 | * Check if a list of decorator ranges are equal to another. 10 | * 11 | * PERF: this requires the two lists to also have the ranges inside them in the 12 | * same order, but this is an okay constraint for us since decorations are 13 | * kept in order, and the odd case where they aren't is okay to re-render for. 14 | */ 15 | 16 | export const isDecoratorRangeListEqual = (list: Range[], another: Range[]): boolean => { 17 | if (list.length !== another.length) { 18 | return false; 19 | } 20 | 21 | for (let i = 0; i < list.length; i++) { 22 | const range = list[i]; 23 | const other = another[i]; 24 | 25 | const { anchor: rangeAnchor, focus: rangeFocus, ...rangeOwnProps } = range; 26 | const { anchor: otherAnchor, focus: otherFocus, ...otherOwnProps } = other; 27 | 28 | if ( 29 | !Range.equals(range, other) || 30 | range[PLACEHOLDER_SYMBOL] !== other[PLACEHOLDER_SYMBOL] || 31 | !shallowCompare(rangeOwnProps, otherOwnProps) 32 | ) { 33 | return false; 34 | } 35 | } 36 | 37 | return true; 38 | }; 39 | -------------------------------------------------------------------------------- /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/huge-document/huge-document.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | @if (mode === 'virtual') { 6 |
7 | 15 | 16 |
17 | } 18 | @if (mode === 'default') { 19 |
20 | 27 | 28 |
29 | } 30 | @if (mode === 'component') { 31 |
32 | @for (item of value; track $index) { 33 | 40 | 41 | } 42 |
43 | } 44 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/view/context.ts: -------------------------------------------------------------------------------- 1 | import { NodeEntry, Range, Element, Ancestor, Text, Path, LeafPosition } from 'slate'; 2 | import { AngularEditor } from '../plugins/angular-editor'; 3 | import { ViewType } from '../types/view'; 4 | 5 | export interface SlateViewContext { 6 | editor: T; 7 | trackBy: (element: Element) => any; 8 | renderElement?: (element: Element) => ViewType; 9 | renderLeaf?: (text: Text) => ViewType; 10 | renderText?: (text: Text) => ViewType; 11 | isStrictDecorate: boolean; 12 | } 13 | 14 | export interface SlateChildrenContext { 15 | parent: Ancestor; 16 | selection: Range; 17 | decorations: Range[]; 18 | decorate: (entry: NodeEntry) => Range[]; 19 | readonly: boolean; 20 | } 21 | 22 | export interface SlateElementContext { 23 | element: T; 24 | selection: Range | null; 25 | decorations: Range[]; 26 | attributes: SlateElementAttributes; 27 | contentEditable?: boolean; 28 | decorate: (entry: NodeEntry) => Range[]; 29 | readonly: boolean; 30 | } 31 | 32 | export interface SlateTextContext { 33 | text: T; 34 | decorations: Range[]; 35 | isLast: boolean; 36 | parent: Element; 37 | } 38 | 39 | export interface SlateLeafContext { 40 | leaf: Text; 41 | leafPosition?: LeafPosition; 42 | text: Text; 43 | parent: Element; 44 | isLast: boolean; 45 | index: number; 46 | } 47 | 48 | export interface SlateElementAttributes { 49 | 'data-slate-node': 'element'; 50 | 'data-slate-void'?: boolean; 51 | 'data-slate-inline'?: boolean; 52 | 'data-slate-key'?: string; 53 | dir?: 'rtl'; 54 | } 55 | 56 | export interface SlateStringContext { 57 | text: string; 58 | elementStringLength: number; 59 | type: 'string' | 'lineBreakEmptyString'; 60 | } 61 | -------------------------------------------------------------------------------- /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/view/flavour/text.ts: -------------------------------------------------------------------------------- 1 | import { SlateTextContext } from '../context'; 2 | import { LeavesRender } from '../render/leaves-render'; 3 | import { BaseFlavour } from './base'; 4 | import { ELEMENT_TO_NODE, NODE_TO_ELEMENT } from 'slate-dom'; 5 | import { Text } from 'slate'; 6 | 7 | export abstract class BaseTextFlavour extends BaseFlavour> { 8 | get text(): T { 9 | return this._context && this._context.text; 10 | } 11 | 12 | leavesRender: LeavesRender; 13 | 14 | getOutletParent = () => { 15 | return this.nativeElement; 16 | }; 17 | 18 | getOutletElement = () => { 19 | return this.nativeElement.querySelector('.children-outlet') as HTMLElement | null; 20 | }; 21 | 22 | onInit() { 23 | this.render(); 24 | this.updateWeakMap(); 25 | this.initialized = true; 26 | this.leavesRender = new LeavesRender(this.viewContext, this.viewContainerRef, this.getOutletParent, this.getOutletElement); 27 | this.leavesRender.initialize(this.context); 28 | } 29 | 30 | updateWeakMap() { 31 | ELEMENT_TO_NODE.set(this.nativeElement, this.text); 32 | NODE_TO_ELEMENT.set(this.text, this.nativeElement); 33 | } 34 | 35 | onDestroy() { 36 | if (NODE_TO_ELEMENT.get(this.text) === this.nativeElement) { 37 | NODE_TO_ELEMENT.delete(this.text); 38 | } 39 | ELEMENT_TO_NODE.delete(this.nativeElement); 40 | this.leavesRender.destroy(); 41 | this.nativeElement?.remove(); 42 | } 43 | 44 | onContextChange() { 45 | if (!this.initialized) { 46 | return; 47 | } 48 | this.rerender(); 49 | this.updateWeakMap(); 50 | this.leavesRender.update(this.context); 51 | } 52 | 53 | abstract render(); 54 | 55 | abstract rerender(); 56 | } 57 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/components/block-card/block-card.ts: -------------------------------------------------------------------------------- 1 | import { getZeroTextNode } from '../../utils/dom'; 2 | 3 | export const SLATE_BLOCK_CARD_CLASS_NAME = 'slate-block-card'; 4 | 5 | export class SlateBlockCard { 6 | centerRootNodes: HTMLElement[]; 7 | 8 | nativeElement: HTMLElement; 9 | 10 | centerContainer: HTMLElement; 11 | 12 | onInit() { 13 | const nativeElement = document.createElement('div'); 14 | nativeElement.classList.add(SLATE_BLOCK_CARD_CLASS_NAME); 15 | this.nativeElement = nativeElement; 16 | this.createContent(); 17 | } 18 | 19 | createContent() { 20 | const leftCaret = document.createElement('span'); 21 | leftCaret.setAttribute(`card-target`, 'card-left'); 22 | leftCaret.classList.add('card-left'); 23 | leftCaret.appendChild(getZeroTextNode()); 24 | const rightCaret = document.createElement('span'); 25 | rightCaret.setAttribute(`card-target`, 'card-right'); 26 | rightCaret.classList.add('card-right'); 27 | rightCaret.appendChild(getZeroTextNode()); 28 | const center = document.createElement('div'); 29 | center.setAttribute(`card-target`, 'card-center'); 30 | this.nativeElement.appendChild(leftCaret); 31 | this.nativeElement.appendChild(center); 32 | this.nativeElement.appendChild(rightCaret); 33 | this.centerContainer = center; 34 | } 35 | 36 | append() { 37 | this.centerRootNodes.forEach(rootNode => !this.centerContainer.contains(rootNode) && this.centerContainer.appendChild(rootNode)); 38 | } 39 | 40 | initializeCenter(rootNodes: HTMLElement[]) { 41 | this.centerRootNodes = rootNodes; 42 | this.append(); 43 | } 44 | 45 | onDestroy() { 46 | this.nativeElement.remove(); 47 | } 48 | } 49 | 50 | export const getBlockCardByNativeElement = (nativeElement: HTMLElement) => { 51 | const blockCardElement = nativeElement?.parentElement?.parentElement; 52 | if (blockCardElement && blockCardElement.classList.contains(SLATE_BLOCK_CARD_CLASS_NAME)) { 53 | return blockCardElement; 54 | } 55 | return null; 56 | }; 57 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 { AngularEditor } from '../plugins/angular-editor'; 7 | import { DOMRange } from 'slate-dom'; 8 | import { TestingLeafFlavour } from './leaf.flavour'; 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 TestingLeafFlavour; 68 | } 69 | return null; 70 | }; 71 | 72 | scrollSelectionIntoView = (editor: AngularEditor, domRange: DOMRange) => {}; 73 | 74 | ngOnInit() {} 75 | 76 | constructor() {} 77 | } 78 | -------------------------------------------------------------------------------- /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 | 51 | export const VIRTUAL_SCROLL_DEFAULT_BUFFER_COUNT = 3; 52 | 53 | export const VIRTUAL_SCROLL_DEFAULT_BLOCK_HEIGHT = 40; 54 | 55 | export const SLATE_DEBUG_KEY = '__SLATE_DEBUG__'; 56 | -------------------------------------------------------------------------------- /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/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/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 { DemoButtonComponent } from './components/button/button.component'; 13 | import { DemoTablesComponent } from './tables/tables.component'; 14 | import { DemoImagesComponent } from './images/images.component'; 15 | import { DemoSearchHighlightingComponent } from './search-highlighting/search-highlighting.component'; 16 | import { DemoLeafComponent } from './search-highlighting/hightlighting-leaf.flavour'; 17 | import { DemoMentionsComponent } from './mentions/mentions.component'; 18 | import { DemoReadonlyComponent } from './readonly/readonly.component'; 19 | import { DemoPlaceholderComponent } from './placeholder/placeholder.component'; 20 | import { DemoElementEditableButtonComponent } from './components/editable-button/editable-button.component'; 21 | import { DemoInlinesComponent } from './inlines/inlines.component'; 22 | import { DemoElementLinkComponent } from './components/link/link.component'; 23 | import { DemoEditableVoidsComponent } from './editable-voids/editable-voids.component'; 24 | import { DemoEmbedsComponent } from './embeds/embeds.component'; 25 | 26 | @NgModule({ 27 | declarations: [AppComponent], 28 | imports: [ 29 | BrowserModule, 30 | BrowserAnimationsModule, 31 | AppRoutingModule, 32 | FormsModule, 33 | SlateModule, 34 | DemoButtonComponent, 35 | DemoRichtextComponent, 36 | DemoMarkdownShortcutsComponent, 37 | DemoHugeDocumentComponent, 38 | DemoElementImageComponent, 39 | DemoTablesComponent, 40 | DemoTablesComponent, 41 | DemoImagesComponent, 42 | DemoSearchHighlightingComponent, 43 | DemoLeafComponent, 44 | DemoMentionsComponent, 45 | DemoReadonlyComponent, 46 | DemoPlaceholderComponent, 47 | DemoElementEditableButtonComponent, 48 | DemoInlinesComponent, 49 | DemoElementLinkComponent, 50 | DemoEditableVoidsComponent, 51 | DemoEmbedsComponent 52 | ], 53 | providers: [], 54 | bootstrap: [AppComponent] 55 | }) 56 | export class AppModule { 57 | constructor() {} 58 | } 59 | -------------------------------------------------------------------------------- /packages/src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import { DOMNode, DOMText, isDOMElement, isDOMNode } from 'slate-dom'; 2 | 3 | export const SlateFragmentAttributeKey = 'data-slate-angular-fragment'; 4 | 5 | /** 6 | * Get x-slate-fragment attribute from data-slate-angular-fragment 7 | */ 8 | const catchSlateFragment = /data-slate-angular-fragment="(.+?)"/m; 9 | export const getSlateFragmentAttribute = (htmlData: string): string | void => { 10 | const [, fragment] = htmlData.match(catchSlateFragment) || []; 11 | return fragment; 12 | }; 13 | 14 | /** 15 | * Check if a DOM node is an element node. 16 | */ 17 | export const isDOMText = (value: any): value is DOMText => { 18 | return isDOMNode(value) && value.nodeType === 3; 19 | }; 20 | 21 | /** 22 | * Get a plaintext representation of the content of a node, accounting for block 23 | * elements which get a newline appended. 24 | * 25 | * The domNode must be attached to the DOM. 26 | */ 27 | export const getPlainText = (domNode: DOMNode) => { 28 | let text = ''; 29 | 30 | if (isDOMText(domNode) && domNode.nodeValue) { 31 | return domNode.nodeValue; 32 | } 33 | 34 | if (isDOMElement(domNode)) { 35 | for (const childNode of Array.from(domNode.childNodes)) { 36 | text += getPlainText(childNode); 37 | } 38 | 39 | const display = getComputedStyle(domNode).getPropertyValue('display'); 40 | 41 | if (display === 'block' || display === 'list' || domNode.tagName === 'BR') { 42 | text += '\n'; 43 | } 44 | } 45 | 46 | return text; 47 | }; 48 | 49 | /** 50 | * Get the dom selection from Shadow Root if possible, otherwise from the document 51 | */ 52 | export const getSelection = (root: Document | ShadowRoot): Selection | null => { 53 | if ((root as { getSelection: () => Selection }).getSelection != null) { 54 | return (root as { getSelection: () => Selection }).getSelection(); 55 | } 56 | return document.getSelection(); 57 | }; 58 | 59 | export const getContentHeight = (element: Element) => { 60 | if (!element) return 0; 61 | const style = window.getComputedStyle(element); 62 | const boxSizing = style.boxSizing; 63 | const height = parseFloat(style.height) || 0; 64 | const paddingTop = parseFloat(style.paddingTop) || 0; 65 | const paddingBottom = parseFloat(style.paddingBottom) || 0; 66 | const totalPadding = paddingTop + paddingBottom; 67 | 68 | const borderTop = parseFloat(style.borderTopWidth) || 0; 69 | const borderBottom = parseFloat(style.borderBottomWidth) || 0; 70 | const totalBorder = borderTop + borderBottom; 71 | 72 | let contentHeight; 73 | if (boxSizing === 'border-box') { 74 | contentHeight = height - totalPadding - totalBorder; 75 | } else { 76 | contentHeight = height; 77 | } 78 | 79 | return Math.max(contentHeight, 0); 80 | }; 81 | 82 | export const getZeroTextNode = (): DOMText => { 83 | return document.createTextNode('\uFEFF'); 84 | }; -------------------------------------------------------------------------------- /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/view/flavour/leaf.ts: -------------------------------------------------------------------------------- 1 | import { Text } from 'slate'; 2 | import { SlateLeafContext } from '../context'; 3 | import { getContentHeight } from '../../utils/dom'; 4 | import { BaseFlavour } from './base'; 5 | 6 | export abstract class BaseLeafFlavour extends BaseFlavour { 7 | placeholderElement: HTMLSpanElement; 8 | 9 | get text(): Text { 10 | return this.context && this.context.text; 11 | } 12 | 13 | get leaf(): Text { 14 | return this.context && this.context.leaf; 15 | } 16 | 17 | onInit() { 18 | this.render(); 19 | this.renderPlaceholder(); 20 | this.initialized = true; 21 | } 22 | 23 | onContextChange() { 24 | if (!this.initialized) { 25 | return; 26 | } 27 | this.rerender(); 28 | this.renderPlaceholder(); 29 | } 30 | 31 | renderPlaceholder() { 32 | // issue-1: IME input was interrupted 33 | // issue-2: IME input focus jumping 34 | // Issue occurs when the span node of the placeholder is before the slateString span node 35 | if (this.context.leaf['placeholder']) { 36 | if (!this.placeholderElement) { 37 | this.createPlaceholder(); 38 | } 39 | this.updatePlaceholder(); 40 | } else { 41 | this.destroyPlaceholder(); 42 | } 43 | } 44 | 45 | createPlaceholder() { 46 | const placeholderElement = document.createElement('span'); 47 | placeholderElement.innerText = this.context.leaf['placeholder']; 48 | placeholderElement.contentEditable = 'false'; 49 | placeholderElement.setAttribute('data-slate-placeholder', 'true'); 50 | this.placeholderElement = placeholderElement; 51 | this.nativeElement.classList.add('leaf-with-placeholder'); 52 | this.nativeElement.appendChild(placeholderElement); 53 | 54 | setTimeout(() => { 55 | const editorElement = this.nativeElement.closest('.the-editor-typo'); 56 | const editorContentHeight = getContentHeight(editorElement); 57 | if (editorContentHeight > 0) { 58 | // Not supported webkitLineClamp exceeds height hiding 59 | placeholderElement.style.maxHeight = `${editorContentHeight}px`; 60 | } 61 | const lineClamp = Math.floor(editorContentHeight / this.nativeElement.offsetHeight) || 0; 62 | placeholderElement.style.webkitLineClamp = `${Math.max(lineClamp, 1)}`; 63 | }); 64 | } 65 | 66 | updatePlaceholder() { 67 | if (this.placeholderElement.innerText !== this.context.leaf['placeholder']) { 68 | this.placeholderElement.innerText = this.context.leaf['placeholder']; 69 | } 70 | } 71 | 72 | destroyPlaceholder() { 73 | if (this.placeholderElement) { 74 | this.placeholderElement.remove(); 75 | this.placeholderElement = null; 76 | this.nativeElement.classList.remove('leaf-with-placeholder'); 77 | } 78 | } 79 | 80 | onDestroy() { 81 | this.nativeElement?.remove(); 82 | } 83 | 84 | abstract render(); 85 | 86 | abstract rerender(); 87 | } 88 | -------------------------------------------------------------------------------- /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 { MarkTypes, RichTextFlavour } from '../flavours/richtext.flavour'; 5 | import { DemoLeafComponent } from './hightlighting-leaf.flavour'; 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 RichTextFlavour; 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/huge-document/huge-document.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, NgZone, HostListener, ViewChild, ElementRef } 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 { FormsModule } from '@angular/forms'; 7 | import { SlateEditable } from '../../../packages/src/components/editable/editable.component'; 8 | import { H1Flavour } from '../flavours/heading.flavour'; 9 | 10 | @Component({ 11 | selector: 'demo-huge-document', 12 | templateUrl: 'huge-document.component.html', 13 | imports: [SlateEditable, FormsModule] 14 | }) 15 | export class DemoHugeDocumentComponent implements OnInit, AfterViewInit { 16 | mode: 'default' | 'component' | 'virtual' = 'virtual'; 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 | virtualScrollConfig = { 30 | enabled: true, 31 | scrollTop: 0, 32 | viewportHeight: 0, 33 | buffer: 3 34 | }; 35 | 36 | @ViewChild('demoContainer') demoContainer?: ElementRef; 37 | 38 | constructor(private ngZone: NgZone) {} 39 | 40 | ngOnInit() { 41 | console.time(); 42 | } 43 | 44 | ngAfterViewInit(): void { 45 | this.ngZone.onStable.pipe(take(1)).subscribe(() => { 46 | console.timeEnd(); 47 | }); 48 | this.syncvirtualScrollConfig(); 49 | } 50 | 51 | switchScrollMode(mode: 'default' | 'component' | 'virtual') { 52 | this.mode = mode; 53 | this.syncvirtualScrollConfig(); 54 | } 55 | 56 | @HostListener('window:scroll') 57 | onWindowScroll() { 58 | this.syncvirtualScrollConfig(); 59 | } 60 | 61 | @HostListener('window:resize') 62 | onWindowResize() { 63 | this.syncvirtualScrollConfig(); 64 | } 65 | 66 | renderElement() { 67 | return (element: any) => { 68 | if (element.type === 'heading-one') { 69 | return H1Flavour; 70 | } 71 | return null; 72 | }; 73 | } 74 | 75 | valueChange(event) {} 76 | 77 | private syncvirtualScrollConfig() { 78 | if (this.mode !== 'virtual') { 79 | return; 80 | } 81 | this.virtualScrollConfig = { 82 | ...this.virtualScrollConfig, 83 | scrollTop: window.scrollY || 0, 84 | viewportHeight: window.innerHeight || 0 85 | }; 86 | } 87 | } 88 | 89 | export const buildInitialValue = () => { 90 | const HEADINGS = 2000; 91 | const PARAGRAPHS = 7; 92 | const initialValue = []; 93 | 94 | for (let h = 0; h < HEADINGS; h++) { 95 | initialValue.push({ 96 | type: 'heading-one', 97 | children: [{ text: faker.lorem.sentence() }] 98 | }); 99 | 100 | for (let p = 0; p < PARAGRAPHS; p++) { 101 | initialValue.push({ 102 | type: 'paragraph', 103 | children: [{ text: faker.lorem.paragraph() }] 104 | }); 105 | } 106 | } 107 | initialValue.push({ 108 | type: 'paragraph', 109 | children: [{ text: '==== END ====' }] 110 | }); 111 | return initialValue; 112 | }; 113 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /types/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 | -------------------------------------------------------------------------------- /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": "~20.3.12", 38 | "@angular/common": "~20.3.12", 39 | "@angular/compiler": "^20.3.12", 40 | "@angular/core": "~20.3.12", 41 | "@angular/forms": "~20.3.12", 42 | "@angular/platform-browser": "~20.3.12", 43 | "@angular/platform-browser-dynamic": "~20.3.12", 44 | "@angular/router": "^20.3.12", 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.117.2", 51 | "slate-dom": "^0.116.0", 52 | "slate-history": "^0.115.0", 53 | "tslib": "^2.6.2", 54 | "zone.js": "~0.15.0" 55 | }, 56 | "devDependencies": { 57 | "@angular-devkit/build-angular": "^20.3.10", 58 | "@angular-devkit/core": "^20.3.10", 59 | "@angular-eslint/builder": "20.2.0", 60 | "@angular-eslint/eslint-plugin": "20.2.0", 61 | "@angular-eslint/eslint-plugin-template": "20.2.0", 62 | "@angular-eslint/schematics": "20.2.0", 63 | "@angular-eslint/template-parser": "20.2.0", 64 | "@angular/cli": "^20.3.10", 65 | "@angular/compiler-cli": "^20.3.12", 66 | "@angular/language-service": "^20.3.12", 67 | "@changesets/changelog-github": "^0.4.8", 68 | "@changesets/cli": "^2.26.0", 69 | "@commitlint/cli": "^19.8.0", 70 | "@commitlint/config-conventional": "^19.8.0", 71 | "@faker-js/faker": "^8.3.1", 72 | "@types/codemirror": "5.60.15", 73 | "@types/is-hotkey": "^0.1.10", 74 | "@types/is-url": "^1.2.32", 75 | "@types/jasmine": "~5.1.4", 76 | "@types/jasminewd2": "~2.0.13", 77 | "@types/node": "^24.3.1", 78 | "@typescript-eslint/eslint-plugin": "^6.10.0", 79 | "@typescript-eslint/parser": "^6.10.0", 80 | "coveralls": "^3.1.1", 81 | "cpx": "^1.5.0", 82 | "eslint": "^8.53.0", 83 | "eslint-config-prettier": "^9.1.0", 84 | "eslint-plugin-prettier": "^5.1.3", 85 | "husky": "^8.0.3", 86 | "is-url": "^1.2.4", 87 | "jasmine": "~5.1.0", 88 | "jasmine-core": "~5.1.1", 89 | "karma": "^6.4.2", 90 | "karma-chrome-launcher": "~3.2.0", 91 | "karma-coverage": "~2.2.1", 92 | "karma-jasmine": "~5.1.0", 93 | "karma-jasmine-html-reporter": "~2.1.0", 94 | "lint-staged": "^15.2.0", 95 | "ng-packagr": "^20.3.2", 96 | "prettier": "^3.1.1", 97 | "pretty-quick": "3.1.3", 98 | "standard-version": "^9.5.0", 99 | "ts-node": "~10.9.2", 100 | "typescript": "~5.9.3" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/src/utils/hotkeys.ts: -------------------------------------------------------------------------------- 1 | import { isKeyHotkey } from 'is-hotkey'; 2 | import { IS_APPLE } from './environment'; 3 | 4 | /** 5 | * Hotkey mappings for each platform. 6 | */ 7 | 8 | const HOTKEYS = { 9 | bold: 'mod+b', 10 | compose: ['down', 'left', 'right', 'up', 'backspace', 'enter'], 11 | moveBackward: 'left', 12 | moveForward: 'right', 13 | moveUp: 'up', 14 | moveDown: 'down', 15 | moveWordBackward: 'ctrl+left', 16 | moveWordForward: 'ctrl+right', 17 | deleteBackward: 'shift?+backspace', 18 | deleteForward: 'shift?+delete', 19 | extendBackward: 'shift+left', 20 | extendForward: 'shift+right', 21 | italic: 'mod+i', 22 | splitBlock: 'shift?+enter', 23 | undo: 'mod+z' 24 | }; 25 | 26 | const APPLE_HOTKEYS = { 27 | moveLineBackward: 'opt+up', 28 | moveLineForward: 'opt+down', 29 | moveWordBackward: 'opt+left', 30 | moveWordForward: 'opt+right', 31 | deleteBackward: ['ctrl+backspace', 'ctrl+h'], 32 | deleteForward: ['ctrl+delete', 'ctrl+d'], 33 | deleteLineBackward: 'cmd+shift?+backspace', 34 | deleteLineForward: ['cmd+shift?+delete', 'ctrl+k'], 35 | deleteWordBackward: 'opt+shift?+backspace', 36 | deleteWordForward: 'opt+shift?+delete', 37 | extendLineBackward: 'opt+shift+up', 38 | extendLineForward: 'opt+shift+down', 39 | redo: 'cmd+shift+z', 40 | transposeCharacter: 'ctrl+t' 41 | }; 42 | 43 | const WINDOWS_HOTKEYS = { 44 | deleteWordBackward: 'ctrl+shift?+backspace', 45 | deleteWordForward: 'ctrl+shift?+delete', 46 | redo: ['ctrl+y', 'ctrl+shift+z'] 47 | }; 48 | 49 | /** 50 | * Create a platform-aware hotkey checker. 51 | */ 52 | 53 | const create = (key: string) => { 54 | const generic = HOTKEYS[key]; 55 | const apple = APPLE_HOTKEYS[key]; 56 | const windows = WINDOWS_HOTKEYS[key]; 57 | const isGeneric = generic && isKeyHotkey(generic); 58 | const isApple = apple && isKeyHotkey(apple); 59 | const isWindows = windows && isKeyHotkey(windows); 60 | 61 | return (event: KeyboardEvent) => { 62 | if (isGeneric && isGeneric(event)) { 63 | return true; 64 | } 65 | if (IS_APPLE && isApple && isApple(event)) { 66 | return true; 67 | } 68 | if (!IS_APPLE && isWindows && isWindows(event)) { 69 | return true; 70 | } 71 | return false; 72 | }; 73 | }; 74 | 75 | /** 76 | * Hotkeys. 77 | */ 78 | 79 | const hotkeys = { 80 | isBold: create('bold'), 81 | isCompose: create('compose'), 82 | isMoveBackward: create('moveBackward'), 83 | isMoveForward: create('moveForward'), 84 | isMoveUp: create('moveUp'), 85 | isMoveDown: create('moveDown'), 86 | isDeleteBackward: create('deleteBackward'), 87 | isDeleteForward: create('deleteForward'), 88 | isDeleteLineBackward: create('deleteLineBackward'), 89 | isDeleteLineForward: create('deleteLineForward'), 90 | isDeleteWordBackward: create('deleteWordBackward'), 91 | isDeleteWordForward: create('deleteWordForward'), 92 | isExtendBackward: create('extendBackward'), 93 | isExtendForward: create('extendForward'), 94 | isExtendLineBackward: create('extendLineBackward'), 95 | isExtendLineForward: create('extendLineForward'), 96 | isItalic: create('italic'), 97 | isMoveLineBackward: create('moveLineBackward'), 98 | isMoveLineForward: create('moveLineForward'), 99 | isMoveWordBackward: create('moveWordBackward'), 100 | isMoveWordForward: create('moveWordForward'), 101 | isRedo: create('redo'), 102 | isSplitBlock: create('splitBlock'), 103 | isTransposeCharacter: create('transposeCharacter'), 104 | isUndo: create('undo') 105 | }; 106 | export default hotkeys; 107 | export { hotkeys }; 108 | -------------------------------------------------------------------------------- /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: '