From 7c2feb0dcad1229ffa8b26bbc2dbfa8fc11a0703 Mon Sep 17 00:00:00 2001 From: Adrien Crivelli Date: Mon, 21 Mar 2022 17:17:13 +0100 Subject: [PATCH] Can set CSS classes on `

` and `` #8782 --- .eslintrc.json | 3 +- .../class-dialog/class-dialog.component.html | 2 +- .../class-dialog/class-dialog.component.scss | 24 --- .../src/lib/editor/editor.component.html | 19 ++ .../src/lib/editor/editor.component.spec.ts | 48 ++++- .../src/lib/utils/items/class-item.ts | 87 ++++----- .../src/lib/utils/items/table-item.ts | 80 +++++++++ .../src/lib/utils/items/text-align-item.ts | 2 +- projects/natural-editor/src/lib/utils/menu.ts | 11 +- .../src/lib/utils/paragraph-with-alignment.ts | 25 ++- .../natural-editor/src/lib/utils/schema.ts | 2 +- .../natural-editor/src/lib/utils/table.ts | 167 ++++++++++++------ src/app/editor/editor.component.ts | 4 +- 13 files changed, 321 insertions(+), 153 deletions(-) create mode 100644 projects/natural-editor/src/lib/utils/items/table-item.ts diff --git a/.eslintrc.json b/.eslintrc.json index 040cc183..162477a7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -58,7 +58,8 @@ "navigator", "sessionStorage", "window" - ] + ], + "prefer-const": ["error"] } }, { diff --git a/projects/natural-editor/src/lib/class-dialog/class-dialog.component.html b/projects/natural-editor/src/lib/class-dialog/class-dialog.component.html index ec7e3284..3ea09b8f 100644 --- a/projects/natural-editor/src/lib/class-dialog/class-dialog.component.html +++ b/projects/natural-editor/src/lib/class-dialog/class-dialog.component.html @@ -1,4 +1,4 @@ -

Sélectionner une couleur

+

Saisir les classes CSS

diff --git a/projects/natural-editor/src/lib/class-dialog/class-dialog.component.scss b/projects/natural-editor/src/lib/class-dialog/class-dialog.component.scss index 3d3b987f..f70946c3 100644 --- a/projects/natural-editor/src/lib/class-dialog/class-dialog.component.scss +++ b/projects/natural-editor/src/lib/class-dialog/class-dialog.component.scss @@ -8,28 +8,4 @@ mat-dialog-content { width: 70vw; max-width: 30em; display: grid; - justify-content: center; - row-gap: 25px; -} - -$size: 25px; -$margin: 3px; -.class { - display: inline-block; - width: $size; - height: $size; - margin: $margin; - cursor: pointer; - - // Make the square bigger - &:hover { - padding: $margin; - margin: 0; - } -} - -$sizeSample: 27px; -.sample { - width: $sizeSample; - height: $sizeSample; } diff --git a/projects/natural-editor/src/lib/editor/editor.component.html b/projects/natural-editor/src/lib/editor/editor.component.html index 207885fa..e2c98b58 100644 --- a/projects/natural-editor/src/lib/editor/editor.component.html +++ b/projects/natural-editor/src/lib/editor/editor.component.html @@ -120,6 +120,17 @@ i18n >Titre 6 + + + + + diff --git a/projects/natural-editor/src/lib/editor/editor.component.spec.ts b/projects/natural-editor/src/lib/editor/editor.component.spec.ts index bef9868c..07c0d57f 100644 --- a/projects/natural-editor/src/lib/editor/editor.component.spec.ts +++ b/projects/natural-editor/src/lib/editor/editor.component.spec.ts @@ -36,8 +36,8 @@ const html = `

h1

right aligned

strong

em

-

a

-

table

+

a

+

table

foo

  1. ol

@@ -76,10 +76,27 @@ describe('NaturalEditorComponent', () => { hostComponent.myValue = html; fixture.detectChanges(); + // TODO this should contain `class="my-table-class"` somewhere but somehow it doesn't even though it does appear correctly in the demo app + const expected = `

h1

+

h1

+

h3

+

h4

+
h5
+
h6
+
code
+

p"foo

+

right aligned

+

strong

+

em

+

a

+

table

+

foo

+ +
  1. ol

+

blockquote

`.replace(/\n/g, ''); + setTimeout(() => { - expect(getProsemirrorContent(fixture)).toBe( - '

h1

h1

h3

h4

h5
h6
code

p"foo

right aligned

strong

em

a

table

foo

  1. ol

blockquote

', - ); + expect(getProsemirrorContent(fixture)).toBe(expected); done(); }); }); @@ -99,11 +116,26 @@ describe('NaturalEditorComponent', () => { hostComponent.myValue = html; fixture.detectChanges(); + const expected = `

h1

+

h1

+

h3

+

h4

+
h5
+
h6
+

code

+

p"foo

+

right aligned

+

strong

+

em

+

a

+

table

+


+ +
  1. ol

+

blockquote

`.replace(/\n/g, ''); setTimeout(() => { - expect(getProsemirrorContent(fixture)).toBe( - '

h1

h1

h3

h4

h5
h6

code

p"foo

right aligned

strong

em

a

table


  1. ol

blockquote

', - ); + expect(getProsemirrorContent(fixture)).toBe(expected); done(); }); }); diff --git a/projects/natural-editor/src/lib/utils/items/class-item.ts b/projects/natural-editor/src/lib/utils/items/class-item.ts index 0fa86ecb..9f0a4a16 100644 --- a/projects/natural-editor/src/lib/utils/items/class-item.ts +++ b/projects/natural-editor/src/lib/utils/items/class-item.ts @@ -1,18 +1,15 @@ -import {Node, NodeType, Schema} from 'prosemirror-model'; -import {AllSelection, TextSelection, Transaction} from 'prosemirror-state'; +import {Node, NodeType} from 'prosemirror-model'; +import {AllSelection, EditorState, TextSelection, Transaction} from 'prosemirror-state'; import {Item} from './item'; import {MatDialog} from '@angular/material/dialog'; import {ClassDialogComponent, ClassDialogData} from '../../class-dialog/class-dialog.component'; -type Alignment = 'left' | 'right' | 'center' | 'justify'; - -function setTextAlign(tr: Transaction, schema: Schema, alignment: null | Alignment): Transaction { +function setClass(tr: Transaction, classValue: string, allowedNodeType: NodeType): Transaction { const {selection, doc} = tr; if (!selection || !doc) { return tr; } const {from, to} = selection; - const {nodes} = schema; const tasks: { node: Node; @@ -20,19 +17,10 @@ function setTextAlign(tr: Transaction, schema: Schema, alignment: null | Alignme nodeType: NodeType; }[] = []; - alignment = alignment || null; - - const allowedNodeTypes = new Set([ - nodes.paragraph, - // nodes['blockquote'], - // nodes['listItem'], - // nodes['heading'], - ]); - doc.nodesBetween(from, to, (node, pos) => { const nodeType = node.type; - const align = node.attrs.align || null; - if (align !== alignment && allowedNodeTypes.has(nodeType)) { + const currentClass = node.attrs.class || null; + if (currentClass !== classValue && allowedNodeType === nodeType) { tasks.push({ node, pos, @@ -50,32 +38,42 @@ function setTextAlign(tr: Transaction, schema: Schema, alignment: null | Alignme const {node, pos, nodeType} = job; const newAttrs = { ...node.attrs, - align: alignment ? alignment : null, + class: classValue ? classValue : null, }; - console.log('newAttrs', newAttrs); + tr = tr.setNodeMarkup(pos, nodeType, newAttrs, node.marks); }); return tr; } +/** + * Returns the first `class` attribute that is non-empty in the selection. + * If not found, return empty string. + */ +function findFirstClassInSelection(state: EditorState): string { + const {selection, doc} = state; + const {from, to} = selection; + let keepLooking = true; + let foundClass: string = ''; + + doc.nodesBetween(from, to, node => { + if (keepLooking && node.attrs.class) { + keepLooking = false; + foundClass = node.attrs.class; + } + + return keepLooking; + }); + + return foundClass; +} + export class ClassItem extends Item { - public constructor(dialog: MatDialog) { + public constructor(dialog: MatDialog, nodeType: NodeType) { super({ active: state => { - const {selection, doc} = state; - const {from, to} = selection; - let keepLooking = true; - let active = false; - doc.nodesBetween(from, to, node => { - if (keepLooking && node.attrs.align === alignment) { - keepLooking = false; - active = true; - } - return keepLooking; - }); - - return active; + return !!findFirstClassInSelection(state); }, enable: state => { @@ -83,33 +81,26 @@ export class ClassItem extends Item { return selection instanceof TextSelection || selection instanceof AllSelection; }, - run: (state, dispatch): boolean => { + run: (state, dispatch, view): void => { dialog .open(ClassDialogComponent, { data: { - class: '', + class: findFirstClassInSelection(state), }, }) .afterClosed() .subscribe(result => { if (dispatch && result) { - const cmd = setCellBackgroundColor(result.class); - cmd(state, dispatch); + const {selection} = state; + + const tr = setClass(state.tr.setSelection(selection), result.class, nodeType); + if (tr.docChanged) { + dispatch?.(tr); + } } view.focus(); }); - - const {schema, selection} = state; - - console.log(this); - const tr = setTextAlign(state.tr.setSelection(selection), schema, this.active ? null : alignment); - if (tr.docChanged) { - dispatch?.(tr); - return true; - } else { - return false; - } }, }); } diff --git a/projects/natural-editor/src/lib/utils/items/table-item.ts b/projects/natural-editor/src/lib/utils/items/table-item.ts new file mode 100644 index 00000000..a32a073e --- /dev/null +++ b/projects/natural-editor/src/lib/utils/items/table-item.ts @@ -0,0 +1,80 @@ +import {EditorState, TextSelection, Transaction} from 'prosemirror-state'; +import {Fragment, Node as ProsemirrorNode, NodeType} from 'prosemirror-model'; +import {tableNodeTypes} from 'prosemirror-tables'; +import {Item} from './item'; + +function createCell( + cellType: NodeType, + cellContent?: Fragment | ProsemirrorNode | Array, +): ProsemirrorNode | null | undefined { + return cellContent ? cellType.createChecked(null, cellContent) : cellType.createAndFill(); +} + +function createTable( + state: EditorState, + rowsCount: number, + colsCount: number, + withHeaderRow: boolean, + cellContent?: Fragment | ProsemirrorNode | Array, +): ProsemirrorNode { + const types = tableNodeTypes(state.schema); + const headerCells = []; + const cells = []; + + for (let index = 0; index < colsCount; index += 1) { + const cell = createCell(types.cell, cellContent); + + if (cell) { + cells.push(cell); + } + + if (withHeaderRow) { + const headerCell = createCell(types.header_cell, cellContent); + + if (headerCell) { + headerCells.push(headerCell); + } + } + } + + const rows = []; + + for (let index = 0; index < rowsCount; index += 1) { + rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells)); + } + + return types.table.createChecked(null, rows); +} + +function addTable( + state: EditorState, + dispatch?: (tr: Transaction) => void, + { + rowsCount = 3, + colsCount = 3, + withHeaderRow = true, + cellContent, + }: { + rowsCount?: number; + colsCount?: number; + withHeaderRow?: boolean; + cellContent?: Fragment | ProsemirrorNode | Array; + } = {}, +): void { + const offset = state.tr.selection.anchor + 1; + + const nodes = createTable(state, rowsCount, colsCount, withHeaderRow, cellContent); + const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView(); + const resolvedPos = tr.doc.resolve(offset); + + // move cursor into table + tr.setSelection(TextSelection.near(resolvedPos)); + + dispatch?.(tr); +} + +export class AddTableItem extends Item { + public constructor() { + super({run: (editor, tr) => addTable(editor, tr)}); + } +} diff --git a/projects/natural-editor/src/lib/utils/items/text-align-item.ts b/projects/natural-editor/src/lib/utils/items/text-align-item.ts index 42a03173..62d52dd3 100644 --- a/projects/natural-editor/src/lib/utils/items/text-align-item.ts +++ b/projects/natural-editor/src/lib/utils/items/text-align-item.ts @@ -50,7 +50,7 @@ function setTextAlign(tr: Transaction, schema: Schema, alignment: null | Alignme ...node.attrs, align: alignment ? alignment : null, }; - console.log('newAttrs', newAttrs); + tr = tr.setNodeMarkup(pos, nodeType, newAttrs, node.marks); }); diff --git a/projects/natural-editor/src/lib/utils/menu.ts b/projects/natural-editor/src/lib/utils/menu.ts index 695dbb50..81c6b410 100644 --- a/projects/natural-editor/src/lib/utils/menu.ts +++ b/projects/natural-editor/src/lib/utils/menu.ts @@ -24,7 +24,6 @@ import { toggleHeaderColumn, toggleHeaderRow, } from 'prosemirror-tables'; -import {addTable} from './table'; import {Item} from './items/item'; import {paragraphWithAlignment} from './paragraph-with-alignment'; import {TextAlignItem} from './items/text-align-item'; @@ -33,6 +32,8 @@ import {LinkItem} from './items/link-item'; import {HorizontalRuleItem} from './items/horizontal-rule-item'; import {cmdToItem, markTypeToItem, menuItemToItem} from './items/utils'; import {wrapListItem} from './items/wrap-list-item'; +import {ClassItem} from './items/class-item'; +import {AddTableItem} from './items/table-item'; export type Key = | 'toggleStrong' @@ -73,7 +74,9 @@ export type Key = | 'toggleHeaderColumn' | 'toggleHeaderRow' | 'toggleHeaderCell' - | 'cellBackgroundColor'; + | 'cellBackgroundColor' + | 'tableClass' + | 'paragraphClass'; export type MenuItems = Partial>; @@ -135,6 +138,7 @@ export function buildMenuItems(schema: Schema, dialog: MatDialog): MenuItems { r.alignRight = new TextAlignItem('right'); r.alignCenter = new TextAlignItem('center'); r.alignJustify = new TextAlignItem('justify'); + r.paragraphClass = new ClassItem(dialog, type); } } @@ -160,7 +164,7 @@ export function buildMenuItems(schema: Schema, dialog: MatDialog): MenuItems { type = schema.nodes.table; if (type) { - r.insertTable = new Item({run: (e, tr) => addTable(e, tr)}); + r.insertTable = new AddTableItem(); r.addColumnBefore = cmdToItem(addColumnBefore); r.addColumnAfter = cmdToItem(addColumnAfter); r.deleteColumn = cmdToItem(deleteColumn); @@ -174,6 +178,7 @@ export function buildMenuItems(schema: Schema, dialog: MatDialog): MenuItems { r.toggleHeaderRow = cmdToItem(toggleHeaderRow); r.toggleHeaderCell = cmdToItem(toggleHeaderCell); r.cellBackgroundColor = new CellBackgroundColorItem(dialog); + r.tableClass = new ClassItem(dialog, type); } return r; diff --git a/projects/natural-editor/src/lib/utils/paragraph-with-alignment.ts b/projects/natural-editor/src/lib/utils/paragraph-with-alignment.ts index f7103e11..f8b4812f 100644 --- a/projects/natural-editor/src/lib/utils/paragraph-with-alignment.ts +++ b/projects/natural-editor/src/lib/utils/paragraph-with-alignment.ts @@ -1,13 +1,22 @@ import {NodeSpec} from 'prosemirror-model'; const ALIGN_PATTERN = /(left|right|center|justify)/; +type Attributes = { + align: null | string; + class: string; + id: string; +}; -// https://github.com/ProseMirror/prosemirror-schema-basic/blob/master/src/schema-basic.js -// :: NodeSpec A plain paragraph textblock. Represented in the DOM -// as a `

` element. +/** + * A plain paragraph textblock. Represented in the DOM + * as a `

` element. + * + * https://github.com/ProseMirror/prosemirror-schema-basic/blob/master/src/schema-basic.js + */ export const paragraphWithAlignment: NodeSpec = { attrs: { align: {default: null}, + class: {default: null}, id: {default: null}, }, content: 'inline*', @@ -15,7 +24,7 @@ export const paragraphWithAlignment: NodeSpec = { parseDOM: [ { tag: 'p', - getAttrs: (dom: Node | string): undefined | {align: null | string; id: string} => { + getAttrs: (dom: Node | string): undefined | Attributes => { if (!(dom instanceof HTMLElement)) { return; } @@ -27,13 +36,13 @@ export const paragraphWithAlignment: NodeSpec = { const id = dom.getAttribute('id') || ''; - return {align, id}; + return {align, class: dom.className, id}; }, }, ], toDOM: node => { const {align, id} = node.attrs; - const attrs: {[key: string]: any} = {}; + const attrs: {[key: string]: string} = {}; let style = ''; if (align && align !== 'left') { @@ -48,6 +57,10 @@ export const paragraphWithAlignment: NodeSpec = { attrs.id = id; } + if (node.attrs.class) { + attrs.class = node.attrs.class; + } + return ['p', attrs, 0]; }, }; diff --git a/projects/natural-editor/src/lib/utils/schema.ts b/projects/natural-editor/src/lib/utils/schema.ts index 10771c7b..5c811250 100644 --- a/projects/natural-editor/src/lib/utils/schema.ts +++ b/projects/natural-editor/src/lib/utils/schema.ts @@ -1,8 +1,8 @@ import {marks, nodes} from 'prosemirror-schema-basic'; import {addListNodes} from 'prosemirror-schema-list'; import {Schema} from 'prosemirror-model'; -import {tableNodes} from 'prosemirror-tables'; import {paragraphWithAlignment} from './paragraph-with-alignment'; +import {tableNodes} from './table'; // Keep only basic elements type BasicNodes = Omit; diff --git a/projects/natural-editor/src/lib/utils/table.ts b/projects/natural-editor/src/lib/utils/table.ts index f54cc593..888f829a 100644 --- a/projects/natural-editor/src/lib/utils/table.ts +++ b/projects/natural-editor/src/lib/utils/table.ts @@ -1,73 +1,124 @@ -import {EditorState, TextSelection, Transaction} from 'prosemirror-state'; -import {Fragment, Node as ProsemirrorNode, NodeType} from 'prosemirror-model'; -import {tableNodeTypes} from 'prosemirror-tables'; +import {TableNodes, TableNodesOptions} from 'prosemirror-tables'; +import {Node as ProsemirrorNode} from 'prosemirror-model'; -function createCell( - cellType: NodeType, - cellContent?: Fragment | ProsemirrorNode | Array, -): ProsemirrorNode | null | undefined { - return cellContent ? cellType.createChecked(null, cellContent) : cellType.createAndFill(); -} - -function createTable( - state: EditorState, - rowsCount: number, - colsCount: number, - withHeaderRow: boolean, - cellContent?: Fragment | ProsemirrorNode | Array, -): ProsemirrorNode { - const types = tableNodeTypes(state.schema); - const headerCells = []; - const cells = []; +type CellAttributes = TableNodesOptions['cellAttributes']; +type Attributes = {[key: string]: number | string | null | number[]}; - for (let index = 0; index < colsCount; index += 1) { - const cell = createCell(types.cell, cellContent); - - if (cell) { - cells.push(cell); - } +function getCellAttrs(dom: Node | string, extraAttrs: CellAttributes): undefined | Attributes { + if (!(dom instanceof HTMLElement)) { + return; + } - if (withHeaderRow) { - const headerCell = createCell(types.header_cell, cellContent); + const widthAttr = dom.getAttribute('data-colwidth'); + const widths = widthAttr && /^\d+(,\d+)*$/.test(widthAttr) ? widthAttr.split(',').map(s => Number(s)) : null; + const colspan = Number(dom.getAttribute('colspan') || 1); + const result: Attributes = { + colspan, + rowspan: Number(dom.getAttribute('rowspan') || 1), + colwidth: widths && widths.length == colspan ? widths : null, + }; - if (headerCell) { - headerCells.push(headerCell); - } - } + for (const prop in extraAttrs) { + const getter = extraAttrs[prop].getFromDOM; + const value = getter && getter(dom); + if (value != null) result[prop] = value; } - const rows = []; + return result; +} + +function setCellAttrs(node: ProsemirrorNode, extraAttrs: CellAttributes): undefined | Record { + const attrs: Record = {}; + if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan; + if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan; + if (node.attrs.colwidth) attrs['data-colwidth'] = node.attrs.colwidth.join(','); - for (let index = 0; index < rowsCount; index += 1) { - rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells)); + for (const prop in extraAttrs) { + const setter = extraAttrs[prop].setDOMAttr; + if (setter) setter(node.attrs[prop], attrs); } - return types.table.createChecked(null, rows); + return attrs; } -export function addTable( - state: EditorState, - dispatch?: (tr: Transaction) => void, - { - rowsCount = 3, - colsCount = 3, - withHeaderRow = true, - cellContent, - }: { - rowsCount?: number; - colsCount?: number; - withHeaderRow?: boolean; - cellContent?: Fragment | ProsemirrorNode | Array; - } = {}, -): void { - const offset = state.tr.selection.anchor + 1; +/** + * This function creates a set of [node + * specs](http://prosemirror.net/docs/ref/#model.SchemaSpec.nodes) for + * `table`, `table_row`, and `table_cell` nodes types. + * + * It is very directly inspired by prosemirror-table + */ +export function tableNodes(options: TableNodesOptions): TableNodes { + const extraAttrs = options.cellAttributes || {}; + const cellAttrs: CellAttributes = { + colspan: {default: 1}, + rowspan: {default: 1}, + colwidth: {default: null}, + }; + + for (const prop in extraAttrs) { + cellAttrs[prop] = { + default: extraAttrs[prop].default, + }; + } - const nodes = createTable(state, rowsCount, colsCount, withHeaderRow, cellContent); - const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView(); - const resolvedPos = tr.doc.resolve(offset); + return { + table: { + attrs: { + class: {default: null}, + }, + content: 'table_row+', + tableRole: 'table', + isolating: true, + group: options.tableGroup, + parseDOM: [ + { + tag: 'table', + getAttrs: (dom: Node | string): undefined | Attributes => { + if (!(dom instanceof HTMLElement)) { + return; + } - // move cursor into table - tr.setSelection(TextSelection.near(resolvedPos)); + return {class: dom.className}; + }, + }, + ], + toDOM: node => { + const attrs: Record = {}; + if (node.attrs.class) { + attrs.class = node.attrs.class; + } - dispatch?.(tr); + return ['table', attrs, ['tbody', 0]]; + }, + }, + table_row: { + content: '(table_cell | table_header)*', + tableRole: 'row', + parseDOM: [{tag: 'tr'}], + toDOM: () => { + return ['tr', 0]; + }, + }, + table_cell: { + content: options.cellContent, + attrs: cellAttrs, + tableRole: 'cell', + isolating: true, + parseDOM: [{tag: 'td', getAttrs: dom => getCellAttrs(dom, extraAttrs)}], + toDOM: node => { + return ['td', setCellAttrs(node, extraAttrs), 0]; + }, + }, + table_header: { + content: options.cellContent, + attrs: cellAttrs, + tableRole: 'header_cell', + isolating: true, + parseDOM: [{tag: 'th', getAttrs: dom => getCellAttrs(dom, extraAttrs)}], + toDOM: node => { + return ['th', setCellAttrs(node, extraAttrs), 0]; + }, + }, + }; } diff --git a/src/app/editor/editor.component.ts b/src/app/editor/editor.component.ts index a60ca35e..fce3b955 100644 --- a/src/app/editor/editor.component.ts +++ b/src/app/editor/editor.component.ts @@ -17,12 +17,12 @@ export class EditorComponent { `; public htmlStringAdvanced = `

Advanced

- +
Wide header
OneTwoThree
FourFiveSix
-

Nap all day cat dog hate mouse eat string barf pillow no baths hate everything but kitty poochy. Sleep on keyboard toy mouse squeak roll over. Mesmerizing birds. Poop on grasses licks paws destroy couch intently sniff hand. The dog smells bad gnaw the corn cob.

+

Nap all day cat dog hate mouse eat string barf pillow no baths hate everything but kitty poochy. Sleep on keyboard toy mouse squeak roll over. Mesmerizing birds. Poop on grasses licks paws destroy couch intently sniff hand. The dog smells bad gnaw the corn cob.

qweqweqwe qwe qw eq eqw

Throw down all the stuff in the kitchen fooled again thinking the dog likes me play riveting piece on synthesizer keyboard chew on cable missing until dinner time. Licks your face milk the cow.