diff --git a/projects/addon-editor/components/editor-socket/editor-socket.component.less b/projects/addon-editor/components/editor-socket/editor-socket.component.less index 668eac150c0c..733fd38ac1fe 100644 --- a/projects/addon-editor/components/editor-socket/editor-socket.component.less +++ b/projects/addon-editor/components/editor-socket/editor-socket.component.less @@ -50,6 +50,14 @@ } } + /* stylelint-disable-next-line */ + .ProseMirror { + video, + audio { + pointer-events: none; + } + } + /* stylelint-disable-next-line */ .tableWrapper, .tui-table-wrapper { diff --git a/projects/addon-editor/components/editor/editor.component.ts b/projects/addon-editor/components/editor/editor.component.ts index 0fad450e0e64..f8c595abb2c4 100644 --- a/projects/addon-editor/components/editor/editor.component.ts +++ b/projects/addon-editor/components/editor/editor.component.ts @@ -62,7 +62,7 @@ export class TuiEditorComponent tools: readonly TuiEditorTool[] = defaultEditorTools; @Output() - fileAttached = new EventEmitter(); + fileAttached = new EventEmitter>>(); @ViewChild(TuiToolbarComponent) readonly toolbar?: TuiToolbarComponent; diff --git a/projects/addon-editor/extensions/index.ts b/projects/addon-editor/extensions/index.ts index b0e84fc98ba5..d775e21cb67c 100644 --- a/projects/addon-editor/extensions/index.ts +++ b/projects/addon-editor/extensions/index.ts @@ -8,6 +8,7 @@ export * from '@taiga-ui/addon-editor/extensions/iframe-editor'; export * from '@taiga-ui/addon-editor/extensions/image-editor'; export * from '@taiga-ui/addon-editor/extensions/indent-outdent'; export * from '@taiga-ui/addon-editor/extensions/link'; +export * from '@taiga-ui/addon-editor/extensions/media'; export * from '@taiga-ui/addon-editor/extensions/starter-kit'; export * from '@taiga-ui/addon-editor/extensions/table'; export * from '@taiga-ui/addon-editor/extensions/table-cell-background'; diff --git a/projects/addon-editor/extensions/link/link.ts b/projects/addon-editor/extensions/link/link.ts index 5e363e773f41..70d7ae62dcdb 100644 --- a/projects/addon-editor/extensions/link/link.ts +++ b/projects/addon-editor/extensions/link/link.ts @@ -1,7 +1,15 @@ +import {tuiParseNodeAttributes} from '@taiga-ui/addon-editor/utils'; import {getHTMLFromFragment} from '@tiptap/core'; import {Link} from '@tiptap/extension-link'; export const TuiLink = Link.extend({ + addAttributes() { + return { + ...this.parent?.(), + ...tuiParseNodeAttributes([`download`]), + }; + }, + addCommands() { return { ...this.parent?.(), diff --git a/projects/addon-editor/extensions/media/audio.extension.ts b/projects/addon-editor/extensions/media/audio.extension.ts new file mode 100644 index 000000000000..ef1b3a5b6ecf --- /dev/null +++ b/projects/addon-editor/extensions/media/audio.extension.ts @@ -0,0 +1,33 @@ +import {tuiGetNestedNodes, tuiParseNodeAttributes} from '@taiga-ui/addon-editor/utils'; +import {Node} from '@tiptap/core'; +import {MarkSpec} from 'prosemirror-model'; + +export const TuiAudio = Node.create({ + name: `audio`, + group: `block`, + content: `source+`, + + addAttributes() { + return tuiParseNodeAttributes([ + `id`, + `class`, + `src`, + `style`, + `controls`, + `loop`, + `muted`, + `preload`, + `autoplay`, + `width`, + `height`, + ]); + }, + + parseHTML(): MarkSpec['parseDOM'] { + return [{tag: `audio`}]; + }, + + renderHTML({node, HTMLAttributes}) { + return [`audio`, HTMLAttributes, ...tuiGetNestedNodes(node)]; + }, +}); diff --git a/projects/addon-editor/extensions/media/index.ts b/projects/addon-editor/extensions/media/index.ts new file mode 100644 index 000000000000..4dd920972e27 --- /dev/null +++ b/projects/addon-editor/extensions/media/index.ts @@ -0,0 +1,3 @@ +export * from './audio.extension'; +export * from './source.extension'; +export * from './video.extension'; diff --git a/projects/addon-editor/extensions/media/ng-package.json b/projects/addon-editor/extensions/media/ng-package.json new file mode 100644 index 000000000000..bab5ebcdb74a --- /dev/null +++ b/projects/addon-editor/extensions/media/ng-package.json @@ -0,0 +1,8 @@ +{ + "lib": { + "entryFile": "index.ts", + "styleIncludePaths": [ + "../../../core/styles" + ] + } +} diff --git a/projects/addon-editor/extensions/media/source.extension.ts b/projects/addon-editor/extensions/media/source.extension.ts new file mode 100644 index 000000000000..1c7e1a9c5d25 --- /dev/null +++ b/projects/addon-editor/extensions/media/source.extension.ts @@ -0,0 +1,27 @@ +import {tuiParseNodeAttributes} from '@taiga-ui/addon-editor/utils'; +import {mergeAttributes, Node} from '@tiptap/core'; +import {MarkSpec} from 'prosemirror-model'; + +export const TuiSource = Node.create({ + name: `source`, + + addAttributes() { + return tuiParseNodeAttributes([ + `src`, + `type`, + `width`, + `height`, + `media`, + `sizes`, + `srcset`, + ]); + }, + + parseHTML(): MarkSpec['parseDOM'] { + return [{tag: `source`}]; + }, + + renderHTML({HTMLAttributes}: Record) { + return [`source`, mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/projects/addon-editor/extensions/media/video.extension.ts b/projects/addon-editor/extensions/media/video.extension.ts new file mode 100644 index 000000000000..b802f580c4d9 --- /dev/null +++ b/projects/addon-editor/extensions/media/video.extension.ts @@ -0,0 +1,33 @@ +import {tuiGetNestedNodes, tuiParseNodeAttributes} from '@taiga-ui/addon-editor/utils'; +import {Node} from '@tiptap/core'; +import {MarkSpec} from 'prosemirror-model'; + +export const TuiVideo = Node.create({ + name: `video`, + group: `block`, + content: `source+`, + + addAttributes() { + return tuiParseNodeAttributes([ + `id`, + `class`, + `src`, + `style`, + `controls`, + `loop`, + `muted`, + `preload`, + `autoplay`, + `width`, + `height`, + ]); + }, + + parseHTML(): MarkSpec['parseDOM'] { + return [{tag: `video`}]; + }, + + renderHTML({node, HTMLAttributes}) { + return [`video`, HTMLAttributes, ...tuiGetNestedNodes(node)]; + }, +}); diff --git a/projects/addon-editor/interfaces/attached.ts b/projects/addon-editor/interfaces/attached.ts index 6b884b0be9ce..044edbecb854 100644 --- a/projects/addon-editor/interfaces/attached.ts +++ b/projects/addon-editor/interfaces/attached.ts @@ -1,6 +1,7 @@ -export interface TuiEditorAttachedFile { +export interface TuiEditorAttachedFile> { name: string; link: string; + attrs?: T; } export interface TuiEditorAttachOptions { diff --git a/projects/addon-editor/utils/get-nested-nodes.ts b/projects/addon-editor/utils/get-nested-nodes.ts new file mode 100644 index 000000000000..d74c9c9324c2 --- /dev/null +++ b/projects/addon-editor/utils/get-nested-nodes.ts @@ -0,0 +1,14 @@ +import {Attrs, Node as NodeElement} from 'prosemirror-model'; + +export function tuiGetNestedNodes(node: NodeElement): Array> { + const nodes: Array> = []; + + // @note: the content field is not array type + node.content.forEach(child => { + if (child instanceof NodeElement) { + nodes.push([child.type.name, child.attrs]); + } + }); + + return nodes; +} diff --git a/projects/addon-editor/utils/index.ts b/projects/addon-editor/utils/index.ts index b860a43978ac..d082f9aaad44 100644 --- a/projects/addon-editor/utils/index.ts +++ b/projects/addon-editor/utils/index.ts @@ -2,12 +2,14 @@ export * from './delete-nodes'; export * from './get-element-point'; export * from './get-gradient-data'; export * from './get-mark-range'; +export * from './get-nested-nodes'; export * from './get-selected-content'; export * from './insert-html'; export * from './insert-text'; export * from './is-selection-in'; export * from './legacy-converter'; export * from './parse-gradient'; +export * from './parse-node-attributes'; export * from './parse-style'; export * from './safe-link-range'; export * from './to-gradient'; diff --git a/projects/addon-editor/utils/parse-node-attributes.ts b/projects/addon-editor/utils/parse-node-attributes.ts new file mode 100644 index 000000000000..ec179498b269 --- /dev/null +++ b/projects/addon-editor/utils/parse-node-attributes.ts @@ -0,0 +1,13 @@ +import {Attribute} from '@tiptap/core'; + +export function tuiParseNodeAttributes( + attrs: string[], +): Record> { + return attrs.reduce((result, attribute) => { + result[attribute] = { + parseHTML: element => element?.getAttribute(`${attribute}`), + }; + + return result; + }, {} as Record>); +} diff --git a/projects/demo/src/modules/components/editor/embed/editor-embed.component.html b/projects/demo/src/modules/components/editor/embed/editor-embed.component.html index ee6c27936dcd..c55695d04508 100644 --- a/projects/demo/src/modules/components/editor/embed/editor-embed.component.html +++ b/projects/demo/src/modules/components/editor/embed/editor-embed.component.html @@ -40,5 +40,14 @@ > + + + + diff --git a/projects/demo/src/modules/components/editor/embed/editor-embed.component.ts b/projects/demo/src/modules/components/editor/embed/editor-embed.component.ts index 8862c99e302e..3413fa4301e7 100644 --- a/projects/demo/src/modules/components/editor/embed/editor-embed.component.ts +++ b/projects/demo/src/modules/components/editor/embed/editor-embed.component.ts @@ -52,4 +52,9 @@ export class ExampleTuiEditorEmbedComponent { './examples/2/embed-tool/embed-tool.module.ts?raw' ), }; + + readonly example3: TuiDocExample = { + HTML: import('./examples/3/index.html?raw'), + TypeScript: import('./examples/3/index.ts?raw'), + }; } diff --git a/projects/demo/src/modules/components/editor/embed/editor-embed.module.ts b/projects/demo/src/modules/components/editor/embed/editor-embed.module.ts index 7c6682cd0d9d..7bc986517b7d 100644 --- a/projects/demo/src/modules/components/editor/embed/editor-embed.module.ts +++ b/projects/demo/src/modules/components/editor/embed/editor-embed.module.ts @@ -17,6 +17,7 @@ import {TuiEditorEmbedExample1} from './examples/1'; import {ExampleTuiYoutubeToolModule} from './examples/1/youtube-tool/youtube-tool.module'; import {TuiEditorEmbedExample2} from './examples/2'; import {ExampleTuiEmbedToolModule} from './examples/2/embed-tool/embed-tool.module'; +import {TuiEditorEmbedExample3} from './examples/3'; @NgModule({ imports: [ @@ -39,6 +40,7 @@ import {ExampleTuiEmbedToolModule} from './examples/2/embed-tool/embed-tool.modu ExampleTuiEditorEmbedComponent, TuiEditorEmbedExample1, TuiEditorEmbedExample2, + TuiEditorEmbedExample3, ], }) export class ExampleTuiEditorEmbedModule {} diff --git a/projects/demo/src/modules/components/editor/embed/examples/3/index.html b/projects/demo/src/modules/components/editor/embed/examples/3/index.html new file mode 100644 index 000000000000..1033ed4d698b --- /dev/null +++ b/projects/demo/src/modules/components/editor/embed/examples/3/index.html @@ -0,0 +1,12 @@ + + +

HTML:

+
+ +

Text:

+

{{ control.value }}

diff --git a/projects/demo/src/modules/components/editor/embed/examples/3/index.ts b/projects/demo/src/modules/components/editor/embed/examples/3/index.ts new file mode 100644 index 000000000000..1983dae94f4b --- /dev/null +++ b/projects/demo/src/modules/components/editor/embed/examples/3/index.ts @@ -0,0 +1,135 @@ +import {Component, Inject, Injector, ViewChild} from '@angular/core'; +import {FormControl, Validators} from '@angular/forms'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; +import { + TUI_ATTACH_FILES_LOADER, + TUI_ATTACH_FILES_OPTIONS, + TUI_EDITOR_EXTENSIONS, + TuiEditorAttachedFile, + TuiEditorComponent, + TuiEditorTool, +} from '@taiga-ui/addon-editor'; +import {tuiPure, tuiTypedFromEvent} from '@taiga-ui/cdk'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; + +@Component({ + selector: 'tui-editor-embed-example-3', + templateUrl: './index.html', + providers: [ + { + provide: TUI_EDITOR_EXTENSIONS, + deps: [Injector], + useFactory: (_injector: Injector) => [ + import('@taiga-ui/addon-editor/extensions/starter-kit').then( + ({StarterKit}) => StarterKit, + ), + import('@tiptap/extension-text-style').then(({TextStyle}) => TextStyle), + import('@taiga-ui/addon-editor/extensions/link').then( + ({TuiLink}) => TuiLink, + ), + import('@taiga-ui/addon-editor/extensions/jump-anchor').then( + ({TuiJumpAnchor}) => TuiJumpAnchor, + ), + import('@taiga-ui/addon-editor/extensions/file-link').then( + ({TuiFileLink}) => TuiFileLink, + ), + import('@taiga-ui/addon-editor/extensions/media').then( + ({TuiVideo}) => TuiVideo, + ), + import('@taiga-ui/addon-editor/extensions/media').then( + ({TuiAudio}) => TuiAudio, + ), + import('@taiga-ui/addon-editor/extensions/media').then( + ({TuiSource}) => TuiSource, + ), + ], + }, + { + provide: TUI_ATTACH_FILES_LOADER, + deps: [], + useFactory: + () => + ([file]: File[]): Observable< + Array> + > => { + const fileReader = new FileReader(); + + // For example, instead of uploading to a file server, + // we convert the result immediately into content to base64 + fileReader.readAsDataURL(file); + + return tuiTypedFromEvent(fileReader, 'load').pipe( + map(() => [ + { + name: file.name, + + /* base64 or link to the file on your server */ + link: String(fileReader.result), + + attrs: { + type: file.type, + }, + }, + ]), + ); + }, + }, + { + provide: TUI_ATTACH_FILES_OPTIONS, + useValue: { + accept: 'video/mp4,video/x-m4v,video/*,audio/x-m4a,audio/*', + multiple: false, + }, + }, + ], + changeDetection, + encapsulation, +}) +export class TuiEditorEmbedExample3 { + @ViewChild(TuiEditorComponent) + private readonly wysiwyg?: TuiEditorComponent; + + readonly builtInTools = [ + TuiEditorTool.Undo, + TuiEditorTool.Link, + TuiEditorTool.Attach, + ]; + + readonly control = new FormControl( + ` +

Here is video:

+ + + +

Here is audio:

+ + +

+ `, + Validators.required, + ); + + constructor(@Inject(DomSanitizer) private readonly sanitizer: DomSanitizer) {} + + @tuiPure + safe(content: string): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(content); + } + + attach([file]: Array>): void { + const tag = `${file.attrs?.type ?? ''}`.split('/')[0]; + + this.wysiwyg?.editor + ?.getOriginTiptapEditor() + .commands.insertContent( + `<${tag} controls width="100%">

Download ${file.name}

`, + ); + } +}