From 580ed4ffbec2410de6a14aa78f569d74d608f810 Mon Sep 17 00:00:00 2001 From: osaydayoub Date: Thu, 1 May 2025 23:45:06 +0300 Subject: [PATCH] feat: add full HTML table supprt using ProseMirror --- package-lock.json | 20 ++- package.json | 1 + projects/ngx-editor/schema/nodes.ts | 32 +++++ projects/ngx-editor/src/lib/Locals.ts | 23 ++++ projects/ngx-editor/src/lib/commands/index.ts | 3 + projects/ngx-editor/src/lib/commands/table.ts | 55 ++++++++ projects/ngx-editor/src/lib/defaultPlugins.ts | 15 ++- .../ngx-editor/src/lib/editor.component.scss | 63 ++++++++- projects/ngx-editor/src/lib/icons/index.ts | 2 + projects/ngx-editor/src/lib/icons/table.ts | 3 + .../src/lib/modules/menu/MenuCommands.ts | 1 + .../menu/dropdown/dropdown.component.ts | 97 ++++++++++--- .../src/lib/modules/menu/menu.component.html | 3 + .../src/lib/modules/menu/menu.component.ts | 105 +++++++++++++-- .../modules/menu/table/table.component.html | 65 +++++++++ .../modules/menu/table/table.component.scss | 0 .../menu/table/table.component.spec.ts | 23 ++++ .../lib/modules/menu/table/table.component.ts | 127 ++++++++++++++++++ projects/ngx-editor/src/lib/types.ts | 17 ++- 19 files changed, 614 insertions(+), 41 deletions(-) create mode 100644 projects/ngx-editor/src/lib/commands/table.ts create mode 100644 projects/ngx-editor/src/lib/icons/table.ts create mode 100644 projects/ngx-editor/src/lib/modules/menu/table/table.component.html create mode 100644 projects/ngx-editor/src/lib/modules/menu/table/table.component.scss create mode 100644 projects/ngx-editor/src/lib/modules/menu/table/table.component.spec.ts create mode 100644 projects/ngx-editor/src/lib/modules/menu/table/table.component.ts diff --git a/package-lock.json b/package-lock.json index 0eb89b91..4624152e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@angular/router": "^19.2.6", "@angular/ssr": "^19.2.7", "express": "^4.18.2", + "prosemirror-tables": "^1.7.1", "rxjs": "~7.8.0", "tslib": "^2.3.1", "zone.js": "~0.15.0" @@ -14572,7 +14573,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", - "dev": true, "license": "MIT" }, "node_modules/os-tmpdir": { @@ -15315,7 +15315,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz", "integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==", - "dev": true, "license": "MIT", "dependencies": { "prosemirror-state": "^1.0.0", @@ -15326,7 +15325,6 @@ "version": "1.25.1", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.1.tgz", "integrity": "sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==", - "dev": true, "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -15348,7 +15346,6 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", - "dev": true, "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", @@ -15356,11 +15353,22 @@ "prosemirror-view": "^1.27.0" } }, + "node_modules/prosemirror-tables": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz", + "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==", + "dependencies": { + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.25.0", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.10.3", + "prosemirror-view": "^1.39.1" + } + }, "node_modules/prosemirror-transform": { "version": "1.10.4", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", - "dev": true, "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" @@ -15370,7 +15378,6 @@ "version": "1.39.2", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.39.2.tgz", "integrity": "sha512-BmOkml0QWNob165gyUxXi5K5CVUgVPpqMEAAml/qzgKn9boLUWVPzQ6LtzXw8Cn1GtRQX4ELumPxqtLTDaAKtg==", - "dev": true, "license": "MIT", "dependencies": { "prosemirror-model": "^1.20.0", @@ -18453,7 +18460,6 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "dev": true, "license": "MIT" }, "node_modules/watchpack": { diff --git a/package.json b/package.json index e8cc2b6e..14f1f43e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@angular/router": "^19.2.6", "@angular/ssr": "^19.2.7", "express": "^4.18.2", + "prosemirror-tables": "^1.7.1", "rxjs": "~7.8.0", "tslib": "^2.3.1", "zone.js": "~0.15.0" diff --git a/projects/ngx-editor/schema/nodes.ts b/projects/ngx-editor/schema/nodes.ts index 50df7888..9f300eb8 100644 --- a/projects/ngx-editor/schema/nodes.ts +++ b/projects/ngx-editor/schema/nodes.ts @@ -1,4 +1,5 @@ import { DOMOutputSpec, Node as ProseMirrorNode, NodeSpec } from 'prosemirror-model'; +import { tableNodes } from 'prosemirror-tables'; import * as sl from 'prosemirror-schema-list'; import { toStyleString } from 'ngx-editor/utils'; @@ -291,6 +292,36 @@ export const image: NodeSpec = { }, }; + +const tableNodeSpecs = tableNodes({ + tableGroup: 'block', + cellContent: 'block+', + cellAttributes: { + background: { + default: null, + getFromDOM(dom: HTMLElement) { + return (dom.style && dom.style.backgroundColor) || null; + }, + setDOMAttr(value: unknown, attrs: Record) { + if (typeof value === 'string' && value) { + attrs['style'] = (attrs['style'] || '') + `background-color: ${value};`; + } + }, + }, + colwidth: { + default: null, + getFromDOM(dom: HTMLElement) { + return dom.dataset['colwidth'] ? dom.dataset['colwidth'].split(',').map(Number) : null; + }, + setDOMAttr(value: unknown, attrs: Record) { + if (Array.isArray(value)) { + attrs['data-colwidth'] = value.join(','); + } + }, + }, + }, +}); + const listItem = { ...sl.listItem, content: 'paragraph block*', @@ -318,6 +349,7 @@ const nodes = { hard_break: hardBreak, code_block: codeBlock, image, + ...tableNodeSpecs, list_item: listItem, ordered_list: orderedList, bullet_list: bulletList, diff --git a/projects/ngx-editor/src/lib/Locals.ts b/projects/ngx-editor/src/lib/Locals.ts index b599c0d3..1f213bb6 100644 --- a/projects/ngx-editor/src/lib/Locals.ts +++ b/projects/ngx-editor/src/lib/Locals.ts @@ -28,6 +28,7 @@ export const defaults: Record> = { insertLink: 'Insert Link', removeLink: 'Remove Link', insertImage: 'Insert Image', + insertTable: 'Insert Table', indent: 'Increase Indent', outdent: 'Decrease Indent', superscript: 'Superscript', @@ -44,6 +45,28 @@ export const defaults: Record> = { title: 'Title', remove: 'Remove', enterValidUrl: 'Please enter a valid URL', + rows: 'Number of Rows', + rowsRequired: 'Rows field is required.', + rowsMin: 'Minimum number of rows must be 1.', + cols: 'Number of Columns', + colsRequired: 'Columns field is required.', + colsMin: 'Minimum number of columns must be 1.', + // Table actions + table:'Table', + addColumnBefore: 'Insert Column Before', + addColumnAfter: 'Insert Column After', + deleteColumn: 'Delete Column', + addRowBefore: 'Insert Row Before', + addRowAfter: 'Insert Row After', + deleteRow: 'Delete Row', + deleteTable: 'Delete Table', + mergeCells: 'Merge Cells', + splitCell: 'Split Cell', + toggleHeaderRow: 'Toggle Header Row', + toggleHeaderColumn: 'Toggle Header Column', + toggleHeaderCell: 'Toggle Header Cells', + setCellBackgroundGreen: 'Make Cell Green', + clearCellBackground: 'Clear Cell Background', }; export type LocalsKeys = keyof typeof defaults; diff --git a/projects/ngx-editor/src/lib/commands/index.ts b/projects/ngx-editor/src/lib/commands/index.ts index 95481ee4..d6e8865a 100644 --- a/projects/ngx-editor/src/lib/commands/index.ts +++ b/projects/ngx-editor/src/lib/commands/index.ts @@ -10,6 +10,8 @@ import TextColor from './TextColor'; import FormatClear from './FormatClear'; import Indent from './Indent'; import History from './History'; +import Table from './table'; + export const STRONG = new Mark('strong'); export const EM = new Mark('em'); @@ -33,6 +35,7 @@ export const ALIGN_RIGHT = new TextAlign('right'); export const ALIGN_JUSTIFY = new TextAlign('justify'); export const LINK = new Link(); export const IMAGE = new Image(); +export const TABLE = new Table(); export const TEXT_COLOR = new TextColor('text_color', 'color'); export const TEXT_BACKGROUND_COLOR = new TextColor('text_background_color', 'backgroundColor'); export const INDENT = new Indent('increase'); diff --git a/projects/ngx-editor/src/lib/commands/table.ts b/projects/ngx-editor/src/lib/commands/table.ts new file mode 100644 index 00000000..de2fa9e0 --- /dev/null +++ b/projects/ngx-editor/src/lib/commands/table.ts @@ -0,0 +1,55 @@ +import { type EditorState, NodeSelection, type Command } from 'prosemirror-state'; +import { Node as ProseMirrorNode } from 'prosemirror-model'; +import { Dispatch } from './types'; + +class Table { + insert(rows: number, cols: number): Command { + return (state: EditorState, dispatch?: Dispatch): boolean => { + const { schema, tr } = state; + const tableType = schema.nodes['table']; + const rowType = schema.nodes['table_row']; + const cellType = schema.nodes['table_cell']; + const headerType = schema.nodes['table_header']; + + const tableRows: ProseMirrorNode[] = []; + + const headerAttrs = { + colspan: cols, + }; + + const headerText = schema.text('Header'); + const paragraph = schema.nodes['paragraph'].create(null, headerText); + const headerCell = headerType.create(headerAttrs, paragraph); + + tableRows.push(rowType.create(null, [headerCell])); + + for (let i = 0; i < rows; i++) { + const cells: ProseMirrorNode[] = []; + for (let j = 0; j < cols; j++) { + cells.push(cellType.createAndFill(null)); + } + tableRows.push(rowType.create(null, cells)); + } + + const table = tableType.create(null, tableRows); + tr.replaceSelectionWith(table).scrollIntoView(); + + if (tr.docChanged) { + dispatch?.(tr); + return true; + } + + return false; + }; + } + + isActive(state: EditorState): boolean { + const { selection } = state; + if (selection instanceof NodeSelection) { + return selection.node.type.name === 'table'; + } + return false; + } +} + +export default Table; diff --git a/projects/ngx-editor/src/lib/defaultPlugins.ts b/projects/ngx-editor/src/lib/defaultPlugins.ts index fa455ccf..b5fdb04d 100644 --- a/projects/ngx-editor/src/lib/defaultPlugins.ts +++ b/projects/ngx-editor/src/lib/defaultPlugins.ts @@ -8,7 +8,7 @@ import { inputRules, wrappingInputRule, textblockTypeInputRule, smartQuotes, emDash, ellipsis, InputRule, } from 'prosemirror-inputrules'; - +import { columnResizing, tableEditing, goToNextCell } from 'prosemirror-tables'; import { markInputRule } from 'ngx-editor/helpers'; interface Options { @@ -137,6 +137,15 @@ export const getKeyboardShortcuts = (schema: Schema, options: ShortcutOptions) = keymap(baseKeymap), ]; + if (schema.nodes['table']) { + plugins.push( + keymap({ + 'Tab': goToNextCell(1), + 'Shift-Tab': goToNextCell(-1), + }) + ); + } + if (options.history) { plugins.push(keymap(historyKeyMap)); } @@ -158,6 +167,10 @@ const getDefaultPlugins = (schema: Schema, options: Options): Plugin[] => { if (options.inputRules) { plugins.push(buildInputRules(schema)); } + + if (schema.nodes['table']) { + plugins.push(columnResizing(), tableEditing()); + } return plugins; }; diff --git a/projects/ngx-editor/src/lib/editor.component.scss b/projects/ngx-editor/src/lib/editor.component.scss index e7b6cb96..3d7046ab 100644 --- a/projects/ngx-editor/src/lib/editor.component.scss +++ b/projects/ngx-editor/src/lib/editor.component.scss @@ -209,7 +209,7 @@ $pointer-style: var(--ngx-editor-click-pointer, default); } .NgxEditor__Dropdown { - min-width: 64px; + width: fit-content; position: relative; display: flex; align-items: center; @@ -227,6 +227,7 @@ $pointer-style: var(--ngx-editor-click-pointer, default); padding: $menubar-text-padding; height: 100%; width: 100%; + max-width: none; &:focus-visible { outline: 1px solid $focus-ring-color; @@ -251,7 +252,8 @@ $pointer-style: var(--ngx-editor-click-pointer, default); border-radius: $popup-border-radius; background-color: $popup-bg-color; z-index: 10; - width: 100%; + width: max-content; + min-width: 100%; top: calc(#{$menubar-height} + 2px); display: flex; flex-direction: column; @@ -262,6 +264,10 @@ $pointer-style: var(--ngx-editor-click-pointer, default); padding: 8px; white-space: nowrap; color: inherit; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + box-sizing: border-box; &:focus-visible { outline: 1px solid $focus-ring-color; @@ -352,3 +358,56 @@ $pointer-style: var(--ngx-editor-click-pointer, default); color: $error-color; } } + +.ProseMirror { + table { + margin: 0; + border-collapse: collapse; + table-layout: fixed; + width: 100%; + overflow: hidden; + border: 1px solid $editor-border-color; + } + th, td { + min-width: 1em; + border: 1px solid $editor-border-color; + padding: 3px 5px; + vertical-align: top; + box-sizing: border-box; + position: relative; + } + .tableWrapper { + margin: 1em 0; + overflow-x: auto; + } + th { + font-weight: bold; + text-align: left; + background-color: #f5f5f5; + } + .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: 0; + width: 4px; + z-index: 20; + background-color: #adf; + pointer-events: none; + } + &.resize-cursor { + cursor: ew-resize; + cursor: col-resize; + } + .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(200, 200, 255, 0.4); + pointer-events: none; + } +} diff --git a/projects/ngx-editor/src/lib/icons/index.ts b/projects/ngx-editor/src/lib/icons/index.ts index cb77d33a..462e858e 100644 --- a/projects/ngx-editor/src/lib/icons/index.ts +++ b/projects/ngx-editor/src/lib/icons/index.ts @@ -11,6 +11,7 @@ import quote from './quote'; import link from './link'; import unlink from './unlink'; import image from './image'; +import table from './table'; import alignLeft from './align_left'; import alignCenter from './align_center'; import alignRight from './align_right'; @@ -42,6 +43,7 @@ export const icons: Record = { link, unlink, image, + table, align_left: alignLeft, align_center: alignCenter, align_right: alignRight, diff --git a/projects/ngx-editor/src/lib/icons/table.ts b/projects/ngx-editor/src/lib/icons/table.ts new file mode 100644 index 00000000..a5a398fb --- /dev/null +++ b/projects/ngx-editor/src/lib/icons/table.ts @@ -0,0 +1,3 @@ +export default ` + +`; \ No newline at end of file diff --git a/projects/ngx-editor/src/lib/modules/menu/MenuCommands.ts b/projects/ngx-editor/src/lib/modules/menu/MenuCommands.ts index 4728ce3f..3273b587 100644 --- a/projects/ngx-editor/src/lib/modules/menu/MenuCommands.ts +++ b/projects/ngx-editor/src/lib/modules/menu/MenuCommands.ts @@ -36,5 +36,6 @@ export const InsertCommands: Record = { export const Link = Commands.LINK; export const Image = Commands.IMAGE; +export const Table = Commands.TABLE; export const TextColor = Commands.TEXT_COLOR; export const TextBackgroundColor = Commands.TEXT_BACKGROUND_COLOR; diff --git a/projects/ngx-editor/src/lib/modules/menu/dropdown/dropdown.component.ts b/projects/ngx-editor/src/lib/modules/menu/dropdown/dropdown.component.ts index 62ca875b..4c24a713 100644 --- a/projects/ngx-editor/src/lib/modules/menu/dropdown/dropdown.component.ts +++ b/projects/ngx-editor/src/lib/modules/menu/dropdown/dropdown.component.ts @@ -2,13 +2,18 @@ import { Component, ElementRef, HostListener, Input, OnDestroy, OnInit, } from '@angular/core'; import { EditorView } from 'prosemirror-view'; +import { EditorState, Transaction } from 'prosemirror-state'; import { Observable, Subscription } from 'rxjs'; import { AsyncPipe, CommonModule } from '@angular/common'; import { NgxEditorService } from '../../../editor.service'; -import { TBHeadingItems } from '../../../types'; +import { TBHeadingItems, TBTableItems } from '../../../types'; import { MenuService } from '../menu.service'; import { ToggleCommands } from '../MenuCommands'; +import { + addColumnAfter, addColumnBefore, deleteColumn, addRowAfter, addRowBefore, deleteRow, + mergeCells, splitCell, setCellAttr, toggleHeaderRow, toggleHeaderColumn, toggleHeaderCell, deleteTable +} from 'prosemirror-tables'; @Component({ selector: 'ngx-dropdown', @@ -21,12 +26,12 @@ export class DropdownComponent implements OnInit, OnDestroy { private updateSubscription: Subscription; @Input() group: string; - @Input() items: TBHeadingItems[]; + @Input() items: (TBHeadingItems | TBTableItems)[] = []; isDropdownOpen = false; - disabledItems: string[] = []; - activeItem: string | null; + disabledItems: (TBHeadingItems | TBTableItems)[] = []; + activeItem: TBHeadingItems | TBTableItems | null; constructor( private ngxeService: NgxEditorService, @@ -52,7 +57,7 @@ export class DropdownComponent implements OnInit, OnDestroy { return this.ngxeService.locals.get(key); } - getIsDropdownActive(item: string): boolean { + getIsDropdownActive(item: TBHeadingItems | TBTableItems): boolean { return this.activeItem === item; } @@ -78,14 +83,38 @@ export class DropdownComponent implements OnInit, OnDestroy { return index; } - selectItem(item: TBHeadingItems): void { - const command = ToggleCommands[item]; - const { state, dispatch } = this.editorView; - command.toggle()(state, dispatch); + selectItem(item: TBHeadingItems | TBTableItems): void { + if (this.group === 'table') { + const tableCommands: { [key in TBTableItems]: (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean } = { + addColumnBefore, + addColumnAfter, + deleteColumn, + addRowBefore, + addRowAfter, + deleteRow, + deleteTable, + mergeCells, + splitCell, + toggleHeaderRow, + toggleHeaderColumn, + toggleHeaderCell, + setCellBackgroundGreen: setCellAttr('background', '#dfd'), + clearCellBackground: setCellAttr('background', null), + }; + const command = tableCommands[item as TBTableItems]; + const { state, dispatch } = this.editorView; + command(state, dispatch) + }else{ + const command = ToggleCommands[item as TBHeadingItems]; + const { state, dispatch } = this.editorView; + command.toggle()(state, dispatch); + } + + this.isDropdownOpen = false; } - onDropdownItemMouseClick(e: MouseEvent, item: TBHeadingItems): void { + onDropdownItemMouseClick(e: MouseEvent, item: TBHeadingItems | TBTableItems): void { e.preventDefault(); // consider only left click @@ -96,7 +125,7 @@ export class DropdownComponent implements OnInit, OnDestroy { this.selectItem(item); } - onDropdownItemKeydown(event: Event, item: TBHeadingItems): void { + onDropdownItemKeydown(event: Event, item: TBHeadingItems | TBTableItems): void { const e = event as KeyboardEvent; e.preventDefault(); this.selectItem(item); @@ -105,21 +134,51 @@ export class DropdownComponent implements OnInit, OnDestroy { private update = (view: EditorView) => { const { state } = view; this.disabledItems = []; - const activeItems = []; - - this.items.forEach((item: TBHeadingItems) => { - const command = ToggleCommands[item]; - const isActive = command.isActive(state); + const activeItems: (TBHeadingItems | TBTableItems)[] = []; + + this.items.forEach((item: TBHeadingItems | TBTableItems) => { + let isActive = false; + let canExecute = false; + + if(this.group === 'table'){ + const tableCommands: { [key in TBTableItems]: (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean } = { + addColumnBefore, + addColumnAfter, + deleteColumn, + addRowBefore, + addRowAfter, + deleteRow, + deleteTable, + mergeCells, + splitCell, + toggleHeaderRow, + toggleHeaderColumn, + toggleHeaderCell, + setCellBackgroundGreen: setCellAttr('background', '#dfd'), + clearCellBackground: setCellAttr('background', null), + }; + const command = tableCommands[item as TBTableItems]; + if (command) { + // Table commands lack 'isActive', so defaulting to false. + isActive = false; + canExecute = command(state); + } + }else{ + const command = ToggleCommands[item as TBHeadingItems]; + if(command){ + isActive = command.isActive(state); + canExecute = command.canExecute(state); + } + } if (isActive) { activeItems.push(item); } - if (!command.canExecute(state)) { + if (!canExecute) { this.disabledItems.push(item); } }); - if (activeItems.length === 1) { [this.activeItem] = activeItems; } else { @@ -129,7 +188,7 @@ export class DropdownComponent implements OnInit, OnDestroy { ngOnInit(): void { this.editorView = this.menuService.editor.view; - + this.update(this.editorView); this.updateSubscription = this.menuService.editor.update.subscribe((view: EditorView) => { this.update(view); }); diff --git a/projects/ngx-editor/src/lib/modules/menu/menu.component.html b/projects/ngx-editor/src/lib/modules/menu/menu.component.html index 9f7fcf34..aad0cbac 100644 --- a/projects/ngx-editor/src/lib/modules/menu/menu.component.html +++ b/projects/ngx-editor/src/lib/modules/menu/menu.component.html @@ -18,6 +18,9 @@ + + + diff --git a/projects/ngx-editor/src/lib/modules/menu/menu.component.ts b/projects/ngx-editor/src/lib/modules/menu/menu.component.ts index 6a96e4b8..acb294dc 100644 --- a/projects/ngx-editor/src/lib/modules/menu/menu.component.ts +++ b/projects/ngx-editor/src/lib/modules/menu/menu.component.ts @@ -3,7 +3,15 @@ import { Component, Input, OnInit, TemplateRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NgxEditorError } from 'ngx-editor/utils'; import Editor from '../../Editor'; -import { Toolbar, ToolbarDropdown, ToolbarItem, ToolbarLink, ToolbarLinkOptions } from '../../types'; +import { + Toolbar, + ToolbarDropdown, + ToolbarItem, + ToolbarLink, + ToolbarLinkOptions, + TBTableItems, + TBHeadingItems, +} from '../../types'; import { ColorPickerComponent } from './color-picker/color-picker.component'; import { DropdownComponent } from './dropdown/dropdown.component'; import { ImageComponent } from './image/image.component'; @@ -11,6 +19,8 @@ import { InsertCommandComponent } from './insert-command/insert-command.componen import { LinkComponent } from './link/link.component'; import { MenuService } from './menu.service'; import { ToggleCommandComponent } from './toggle-command/toggle-command.component'; +import { TableComponent } from './table/table.component'; +import { EditorState } from 'prosemirror-state'; export const DEFAULT_TOOLBAR: Toolbar = [ ['bold', 'italic'], @@ -19,6 +29,27 @@ export const DEFAULT_TOOLBAR: Toolbar = [ ['ordered_list', 'bullet_list'], [{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }], ['link', 'image'], + [ + 'table', + { + table: [ + 'addColumnBefore', + 'addColumnAfter', + 'deleteColumn', + 'addRowBefore', + 'addRowAfter', + 'deleteRow', + 'deleteTable', + 'mergeCells', + 'splitCell', + 'toggleHeaderRow', + 'toggleHeaderColumn', + 'toggleHeaderCell', + 'setCellBackgroundGreen', + 'clearCellBackground', + ], + }, + ], ['text_color', 'background_color'], ['align_left', 'align_center', 'align_right', 'align_justify'], ['format_clear'], @@ -28,6 +59,27 @@ export const TOOLBAR_MINIMAL: Toolbar = [ ['bold', 'italic'], [{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }], ['link', 'image'], + [ + 'table', + { + table: [ + 'addColumnBefore', + 'addColumnAfter', + 'deleteColumn', + 'addRowBefore', + 'addRowAfter', + 'deleteRow', + 'deleteTable', + 'mergeCells', + 'splitCell', + 'toggleHeaderRow', + 'toggleHeaderColumn', + 'toggleHeaderCell', + 'setCellBackgroundGreen', + 'clearCellBackground', + ], + }, + ], ['text_color', 'background_color'], ]; @@ -38,6 +90,27 @@ export const TOOLBAR_FULL: Toolbar = [ ['ordered_list', 'bullet_list'], [{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }], ['link', 'image'], + [ + 'table', + { + table: [ + 'addColumnBefore', + 'addColumnAfter', + 'deleteColumn', + 'addRowBefore', + 'addRowAfter', + 'deleteRow', + 'deleteTable', + 'mergeCells', + 'splitCell', + 'toggleHeaderRow', + 'toggleHeaderColumn', + 'toggleHeaderCell', + 'setCellBackgroundGreen', + 'clearCellBackground', + ], + }, + ], ['text_color', 'background_color'], ['align_left', 'align_center', 'align_right', 'align_justify'], ['horizontal_rule', 'format_clear', 'indent', 'outdent'], @@ -77,6 +150,7 @@ const DEFAULT_COLOR_PRESETS = [ InsertCommandComponent, LinkComponent, ImageComponent, + TableComponent, ], }) export class NgxEditorMenuComponent implements OnInit { @@ -104,15 +178,24 @@ export class NgxEditorMenuComponent implements OnInit { 'subscript', ]; - insertCommands: ToolbarItem[] = [ - 'horizontal_rule', - 'format_clear', - 'indent', - 'outdent', - 'undo', - 'redo', - ]; + insertCommands: ToolbarItem[] = ['horizontal_rule', 'format_clear', 'indent', 'outdent', 'undo', 'redo']; + tableCommands: TBTableItems[] = [ + 'addColumnBefore', + 'addColumnAfter', + 'deleteColumn', + 'addRowBefore', + 'addRowAfter', + 'deleteRow', + 'deleteTable', + 'mergeCells', + 'splitCell', + 'toggleHeaderRow', + 'toggleHeaderColumn', + 'toggleHeaderCell', + 'setCellBackgroundGreen', + 'clearCellBackground', + ]; iconContainerClass = ['NgxEditor__MenuItem', 'NgxEditor__MenuItem--IconContainer']; dropdownContainerClass = ['NgxEditor__Dropdown']; seperatorClass = ['NgxEditor__Seperator']; @@ -141,10 +224,10 @@ export class NgxEditorMenuComponent implements OnInit { } isDropDown(item: ToolbarItem): boolean { - if ((item as ToolbarDropdown)?.heading) { + const dropdown = item as ToolbarDropdown; + if (dropdown?.heading || dropdown?.table) { return true; } - return false; } diff --git a/projects/ngx-editor/src/lib/modules/menu/table/table.component.html b/projects/ngx-editor/src/lib/modules/menu/table/table.component.html new file mode 100644 index 00000000..e0313c73 --- /dev/null +++ b/projects/ngx-editor/src/lib/modules/menu/table/table.component.html @@ -0,0 +1,65 @@ + + +
+
+
+
+ + +
+ {{ form.get('rows').errors?.['required'] && getLabel('rowsRequired') | async }} + {{ form.get('rows').errors?.['min'] && getLabel('rowsMin') | async }} +
+
+
+ +
+
+ + +
+ {{ form.get('cols').errors?.['required'] && getLabel('colsRequired') | async }} + {{ form.get('cols').errors?.['min'] && getLabel('colsMin') | async }} +
+
+
+ + +
+
diff --git a/projects/ngx-editor/src/lib/modules/menu/table/table.component.scss b/projects/ngx-editor/src/lib/modules/menu/table/table.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/projects/ngx-editor/src/lib/modules/menu/table/table.component.spec.ts b/projects/ngx-editor/src/lib/modules/menu/table/table.component.spec.ts new file mode 100644 index 00000000..3c9a5e48 --- /dev/null +++ b/projects/ngx-editor/src/lib/modules/menu/table/table.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TableComponent } from './table.component'; + +describe('TableComponent', () => { + let component: TableComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TableComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/ngx-editor/src/lib/modules/menu/table/table.component.ts b/projects/ngx-editor/src/lib/modules/menu/table/table.component.ts new file mode 100644 index 00000000..91f19598 --- /dev/null +++ b/projects/ngx-editor/src/lib/modules/menu/table/table.component.ts @@ -0,0 +1,127 @@ +import { Component, ElementRef, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { AsyncPipe, CommonModule } from '@angular/common'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { uniq } from 'ngx-editor/utils'; +import { EditorView } from 'prosemirror-view'; +import { Observable, Subscription } from 'rxjs'; +import { NgxEditorService } from '../../../editor.service'; +import { SanitizeHtmlPipe } from '../../../pipes/sanitize/sanitize-html.pipe'; +import { HTML } from '../../../trustedTypesUtil'; +import { MenuService } from '../menu.service'; + +import { Table as TableCommand } from '../MenuCommands'; + +@Component({ + selector: 'ngx-table', + imports: [AsyncPipe, SanitizeHtmlPipe, ReactiveFormsModule, CommonModule], + templateUrl: './table.component.html', + styleUrl: './table.component.scss', +}) +export class TableComponent implements OnInit, OnDestroy { + showPopup = false; + isActive = false; + private componentId = uniq(); + private updateSubscription: Subscription; + + form = new FormGroup({ + rows: new FormControl(2, [Validators.required, Validators.min(1)]), + cols: new FormControl(3, [Validators.required, Validators.min(1)]), + }); + + private editorView: EditorView; + + constructor( + private el: ElementRef, + private ngxeService: NgxEditorService, + private menuService: MenuService, + ) {} + + get icon(): HTML { + return this.ngxeService.getIcon('table'); + } + + @HostListener('document:mousedown', ['$event']) onDocumentClick(e: MouseEvent): void { + if (!this.el.nativeElement.contains(e.target) && this.showPopup) { + this.hideForm(); + } + } + + getId(name: string): string { + return `${name}-${this.componentId}`; + } + + getLabel(key: string): Observable { + return this.ngxeService.locals.get(key); + } + + private hideForm(): void { + this.showPopup = false; + this.form.reset({ + rows: 2, + cols: 3, + }); + } + + togglePopup(): void { + this.showPopup = !this.showPopup; + + if (this.showPopup) { + this.fillForm(); + } + } + + onTogglePopupMouseClick(e: MouseEvent): void { + if (e.button !== 0) { + return; + } + + this.togglePopup(); + } + + onTogglePopupKeydown(): void { + this.togglePopup(); + } + + private fillForm(): void { + this.form.setValue({ + rows: 2, + cols: 3, + }); + } + + private update = (view: EditorView) => { + const { state } = view; + this.isActive = TableCommand.isActive(state); + }; + + insertTable(e: MouseEvent): void { + e.preventDefault(); + const { rows, cols } = this.form.getRawValue(); + const { dispatch, state } = this.editorView; + + if (!state || !dispatch) { + console.error('Editor state or dispatch not available'); + return; + } + + const success = TableCommand.insert(rows, cols)(state, dispatch); + if (success) { + this.editorView.focus(); + this.hideForm(); + } else { + console.error('Failed to insert table'); + } + } + + ngOnInit(): void { + this.editorView = this.menuService.editor.view; + + this.updateSubscription = this.menuService.editor.update.subscribe((view: EditorView) => { + this.update(view); + }); + } + + ngOnDestroy(): void { + this.updateSubscription.unsubscribe(); + } +} diff --git a/projects/ngx-editor/src/lib/types.ts b/projects/ngx-editor/src/lib/types.ts index c52fbee8..66202773 100644 --- a/projects/ngx-editor/src/lib/types.ts +++ b/projects/ngx-editor/src/lib/types.ts @@ -13,6 +13,20 @@ type TCR = { }; export type TBHeadingItems = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; +export type TBTableItems='addColumnBefore' +| 'addColumnAfter' +| 'deleteColumn' +| 'addRowBefore' +| 'addRowAfter' +| 'deleteRow' +| 'deleteTable' +| 'mergeCells' +| 'splitCell' +| 'toggleHeaderRow' +| 'toggleHeaderColumn' +| 'toggleHeaderCell' +| 'setCellBackgroundGreen' +| 'clearCellBackground'; export type TBItems = 'bold' | 'italic' | 'code' @@ -23,6 +37,7 @@ export type TBItems = 'bold' | 'bullet_list' | 'link' | 'image' +| 'table' | 'text_color' | 'background_color' | 'align_left' @@ -38,7 +53,7 @@ export type TBItems = 'bold' | 'undo' | 'redo'; -export type ToolbarDropdown = { heading?: TBHeadingItems[] }; +export type ToolbarDropdown = { heading?: TBHeadingItems[]; table?: TBTableItems[] }; export type ToolbarLinkOptions = Partial; export type ToolbarLink = { link: ToolbarLinkOptions }; export type ToolbarCustomMenuItem = (editorView: EditorView) => TCR;