From 3d92fdf463e96c72d21c97f7303d29e1e2979903 Mon Sep 17 00:00:00 2001 From: Nicolas Bayet Date: Tue, 17 Mar 2020 16:34:57 +0100 Subject: [PATCH] integration --- .gitignore | 1 + bundles/BasicEditor.ts | 7 +- bundles/OdooWebsiteEditor.ts | 80 ++++++ dev/odoo-integration.js | 13 + doc/odoo_integration_workflow.md | 30 +++ examples/utils/jabberwocky.xml | 192 ++++++++++--- package.json | 1 + .../odoo-integration.ts | 22 ++ packages/core/src/Dispatcher.ts | 6 +- packages/core/src/EventNormalizer.ts | 52 ++-- packages/core/src/JWEditor.ts | 2 +- packages/plugin-char/src/Char.ts | 35 +++ packages/plugin-dom/src/Dom.ts | 25 +- .../plugin-odoo-snippets/src/OdooBindings.ts | 157 +++++++++++ .../plugin-odoo-snippets/src/OdooSnippet.ts | 252 ++++++++++++++++++ .../src/TableCellDomParser.ts | 34 +++ .../src/TableCellDomRenderer.ts | 45 ++++ .../plugin-odoo-snippets/src/TableCellNode.ts | 206 ++++++++++++++ .../src/TableDomParser.ts | 141 ++++++++++ .../src/TableDomRenderer.ts | 56 ++++ .../plugin-odoo-snippets/src/TableNode.ts | 192 +++++++++++++ .../src/TableRowDomParser.ts | 72 +++++ .../src/TableRowDomRenderer.ts | 24 ++ .../plugin-odoo-snippets/src/TableRowNode.ts | 44 +++ packages/plugin-parser/src/Parser.ts | 10 + src/builder/builder-odoo.ts | 25 ++ test/core/JWEditor.test.ts | 69 +++++ webpack.config.js | 8 + 28 files changed, 1739 insertions(+), 62 deletions(-) create mode 100644 bundles/OdooWebsiteEditor.ts create mode 100644 dev/odoo-integration.js create mode 100644 doc/odoo_integration_workflow.md create mode 100644 packages/build-odoo-integration/odoo-integration.ts create mode 100644 packages/plugin-odoo-snippets/src/OdooBindings.ts create mode 100644 packages/plugin-odoo-snippets/src/OdooSnippet.ts create mode 100644 packages/plugin-odoo-snippets/src/TableCellDomParser.ts create mode 100644 packages/plugin-odoo-snippets/src/TableCellDomRenderer.ts create mode 100644 packages/plugin-odoo-snippets/src/TableCellNode.ts create mode 100644 packages/plugin-odoo-snippets/src/TableDomParser.ts create mode 100644 packages/plugin-odoo-snippets/src/TableDomRenderer.ts create mode 100644 packages/plugin-odoo-snippets/src/TableNode.ts create mode 100644 packages/plugin-odoo-snippets/src/TableRowDomParser.ts create mode 100644 packages/plugin-odoo-snippets/src/TableRowDomRenderer.ts create mode 100644 packages/plugin-odoo-snippets/src/TableRowNode.ts create mode 100644 src/builder/builder-odoo.ts create mode 100644 test/core/JWEditor.test.ts diff --git a/.gitignore b/.gitignore index cacd6356b..a55219300 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,6 @@ ehthumbs.db Thumbs.db data/ build/ +!src/build/ package-lock.json coverage/ diff --git a/bundles/BasicEditor.ts b/bundles/BasicEditor.ts index ed9cb502d..b1e3888d2 100644 --- a/bundles/BasicEditor.ts +++ b/bundles/BasicEditor.ts @@ -39,7 +39,12 @@ export class BasicEditor extends JWEditor { [Parser], [Renderer], [Keymap], - [Dom], + [ + Dom, + { + afterRender: (editable: HTMLElement) => console.log('afterRender'), + }, + ], [Inline], [Char], [LineBreak], diff --git a/bundles/OdooWebsiteEditor.ts b/bundles/OdooWebsiteEditor.ts new file mode 100644 index 000000000..57eaf3c16 --- /dev/null +++ b/bundles/OdooWebsiteEditor.ts @@ -0,0 +1,80 @@ +import JWEditor from '../packages/core/src/JWEditor'; +import { Parser } from '../packages/plugin-parser/src/Parser'; +import { Dom } from '../packages/plugin-dom/src/Dom'; +import { Char } from '../packages/plugin-char/src/Char'; +import { LineBreak } from '../packages/plugin-linebreak/src/LineBreak'; +import { Heading } from '../packages/plugin-heading/src/Heading'; +import { Paragraph } from '../packages/plugin-paragraph/src/Paragraph'; +import { List } from '../packages/plugin-list/src/List'; +import { Indent } from '../packages/plugin-indent/src/Indent'; +import { ParagraphNode } from '../packages/plugin-paragraph/src/ParagraphNode'; +import { Span } from '../packages/plugin-span/src/Span'; +import { Bold } from '../packages/plugin-bold/src/Bold'; +import { Italic } from '../packages/plugin-italic/src/Italic'; +import { Underline } from '../packages/plugin-underline/src/Underline'; +import { Inline } from '../packages/plugin-inline/src/Inline'; +import { Link } from '../packages/plugin-link/src/Link'; +import { Divider } from '../packages/plugin-divider/src/Divider'; +import { Image } from '../packages/plugin-image/src/Image'; +import { Subscript } from '../packages/plugin-subscript/src/Subscript'; +import { Superscript } from '../packages/plugin-superscript/src/Superscript'; +import { Blockquote } from '../packages/plugin-blockquote/src/Blockquote'; +import { Youtube } from '../packages/plugin-youtube/src/Youtube'; +import { Table } from '../packages/plugin-table/src/Table'; +import { Metadata } from '../packages/plugin-metadata/src/Metadata'; +import { Renderer } from '../packages/plugin-renderer/src/Renderer'; +import { Keymap } from '../packages/plugin-keymap/src/Keymap'; +import { Align } from '../packages/plugin-align/src/Align'; +import { Pre } from '../packages/plugin-pre/src/Pre'; +import { TextColor } from '../packages/plugin-textcolor/src/TextColor'; +import { BackgroundColor } from '../packages/plugin-backgroundcolor/src/BackgroundColor'; +import { OdooSnippet } from '../packages/plugin-odoo-snippets/src/OdooSnippet'; + +interface OdooWebsiteEditorConfig { + afterRender?: Function; +} +export class OdooWebsiteEditor extends JWEditor { + constructor(options?: OdooWebsiteEditorConfig) { + super(); + + this.configure({ + createBaseContainer: () => new ParagraphNode(), + plugins: [ + [Parser], + [Renderer], + [Keymap], + [ + Dom, + { + afterRender: options?.afterRender, + }, + ], + [Inline], + [Char], + [LineBreak], + [Heading], + [Paragraph], + [List], + [Indent], + [Span], + [Bold], + [Italic], + [Underline], + [Link], + [Divider], + [Image], + [Subscript], + [Superscript], + [Blockquote], + [Youtube], + [Table], + [Metadata], + [Align], + [Pre], + [TextColor], + [BackgroundColor], + [OdooSnippet], + ], + }); + } +} diff --git a/dev/odoo-integration.js b/dev/odoo-integration.js new file mode 100644 index 000000000..42a4e2174 --- /dev/null +++ b/dev/odoo-integration.js @@ -0,0 +1,13 @@ +odoo.define('web_editor.jabberwock', function(require) { + // 'use strict'; + + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = 'http://localhost:8080/odoo-integration.js'; + document.getElementsByTagName('head')[0].appendChild(script); + return new Promise(resolve => { + script.onload = () => { + resolve(JWEditor); + }; + }); +}); diff --git a/doc/odoo_integration_workflow.md b/doc/odoo_integration_workflow.md new file mode 100644 index 000000000..0f96a4698 --- /dev/null +++ b/doc/odoo_integration_workflow.md @@ -0,0 +1,30 @@ +# Odoo integration + +To develop Jabberwock in Odoo, follow these steps: +1) Use the dev mode for the live reloading feature of Webpack. (optional) +2) Build the doc and include it in Odoo. + +## 1) Use the dev mode for the live reloading feature of Webpack. + +Temporarily replace the library by the following script. +```bash +cp dev/odoo-integration.js /addons/web_editor/static/lib/jabberwock/jabberwock.js +``` +`odoo-integration.js` will load the script `build-full.js`. +The default loaded script is `http://localhost:8080/build-full.js`. +You might want to change the port if your development port is not "8080". + +Launch the development server (on port 8080 by default): +```bash +npm run dev +``` + +Once finished developing, rebuild the source and put it back in Odoo. + +## 2) Build the source and include it in Odoo. + +```bash +npm run build +npm run build-odoo +cp build/webpack/build/build-full-odoo.js /addons/web_editor/static/lib/jabberwock/jabberwock.js +``` diff --git a/examples/utils/jabberwocky.xml b/examples/utils/jabberwocky.xml index 3afe0500b..b418e225f 100644 --- a/examples/utils/jabberwocky.xml +++ b/examples/utils/jabberwocky.xml @@ -1,36 +1,156 @@ -

Jabberwocky

-

by Lewis Carroll

-

’Twas brillig, and the slithy toves
-Did gyre and gimble in the wabe:
-All mimsy were the borogoves,
-And the mome raths outgrabe.
-
-“Beware the Jabberwock, my son!
-The jaws that bite, the claws that catch!
-Beware the Jubjub bird, and shun
-The frumious Bandersnatch!”
-
-He took his vorpal sword in hand;
-Long time the manxome foe he sought—
-So rested he by the Tumtum tree
-And stood awhile in thought.
-
-And, as in uffish thought he stood,
-The Jabberwock, with eyes of flame,
-Came whiffling through the tulgey wood,
-And burbled as it came!
-
-One, two! One, two! And through and through
-The vorpal blade went snicker-snack!
-He left it dead, and with its head
-He went galumphing back.
-
-“And hast thou slain the Jabberwock?
-Come to my arms, my beamish boy!
-O frabjous day! Callooh! Callay!”
-He chortled in his joy.
-
-’Twas brillig, and the slithy toves
-Did gyre and gimble in the wabe:
-All mimsy were the borogoves,
-And the mome raths outgrabe.

\ No newline at end of file + diff --git a/package.json b/package.json index b8de3b216..a73c4498e 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "webpack-dev-server", "build": "rm -rf build/examples; mkdir -p build/examples/; cp -r ./examples ./build; webpack", + "build-odoo": "ts-node ./src/builder/builder-odoo.ts", "perf": "karma start --include-files test/**/*.perf.ts", "coverage": "karma start --coverage", "debug": "karma start --no-browsers --debug", diff --git a/packages/build-odoo-integration/odoo-integration.ts b/packages/build-odoo-integration/odoo-integration.ts new file mode 100644 index 000000000..c6b693549 --- /dev/null +++ b/packages/build-odoo-integration/odoo-integration.ts @@ -0,0 +1,22 @@ +import { BasicEditor } from './../../bundles/BasicEditor'; +import { Dom } from './../plugin-dom/src/Dom'; +import { DevTools } from '../plugin-devtools/src/DevTools'; +import { OdooWebsiteEditor } from '../../bundles/OdooWebsiteEditor'; +import { VRange, withRange } from '../core/src/VRange'; +import { + createExecCommandHelper, + createExecCommandHelpersForOdoo, + createExecCommandHelpersForOdoo2, +} from '../plugin-odoo-snippets/src/OdooBindings'; + +export { + OdooWebsiteEditor, + BasicEditor, + DevTools, + Dom, + withRange, + VRange, + createExecCommandHelper, + createExecCommandHelpersForOdoo, + createExecCommandHelpersForOdoo2, +}; diff --git a/packages/core/src/Dispatcher.ts b/packages/core/src/Dispatcher.ts index de12fe6d1..27bfcc57d 100644 --- a/packages/core/src/Dispatcher.ts +++ b/packages/core/src/Dispatcher.ts @@ -10,7 +10,7 @@ export interface CommandImplementation extends Contextual { export interface CommandParams { context?: Context; } -export type CommandHandler = (args) => void; +export type CommandHandler = (args) => any; export type CommandHook = (params: CommandParams, commandId: string) => void; export class Dispatcher { @@ -42,13 +42,14 @@ export class Dispatcher { return; } + let result: any; const [command, context] = this.editor.contextManager.match(commands, params.context); if (command) { // Update command arguments with the computed execution context. const args = { ...params, context }; // Call command handler. - await command.handler(args); + result = await command.handler(args); // Call command hooks. const hooks = this.commandHooks[commandId] || []; @@ -57,6 +58,7 @@ export class Dispatcher { await hookCallback(args, commandId); } } + return result; } /** diff --git a/packages/core/src/EventNormalizer.ts b/packages/core/src/EventNormalizer.ts index a2af81541..f187ef3e6 100644 --- a/packages/core/src/EventNormalizer.ts +++ b/packages/core/src/EventNormalizer.ts @@ -1520,14 +1520,19 @@ export class EventNormalizer { * @param {MouseEvent} ev */ _onPointerDown(ev: MouseEvent | TouchEvent): void { - // Don't trigger events on the editable if the click was done outside of - // the editable itself or on something else than an element. - if (ev.target instanceof Element && this.editable.contains(ev.target)) { - this._mousedownInEditable = true; - this._initialCaretPosition = this._getEventCaretPosition(ev); - this._selectionHasChanged = false; - this._followsPointerAction = true; - } else { + try { + // Don't trigger events on the editable if the click was done outside of + // the editable itself or on something else than an element. + if (ev.target instanceof Element && this.editable.contains(ev.target)) { + this._mousedownInEditable = true; + this._initialCaretPosition = this._getEventCaretPosition(ev); + this._selectionHasChanged = false; + this._followsPointerAction = true; + } else { + this._mousedownInEditable = false; + this._initialCaretPosition = undefined; + } + } catch (e) { this._mousedownInEditable = false; this._initialCaretPosition = undefined; } @@ -1538,19 +1543,24 @@ export class EventNormalizer { * @param ev */ _onPointerUp(ev: MouseEvent): void { - // Don't trigger events on the editable if the click was done outside of - // the editable itself or on something else than an element. - if (this._mousedownInEditable && ev.target instanceof Element) { - // When the users clicks in the DOM, the range is set in the next - // tick. The observation of the resulting range must thus be delayed - // to the next tick as well. Store the data we have now before it - // gets invalidated by the redrawing of the DOM. - this._initialCaretPosition = this._getEventCaretPosition(ev); - - this._pointerSelectionTimeout = new Timeout(() => { - return this._analyzeSelectionChange(ev); - }); - this._triggerEventBatch(this._pointerSelectionTimeout.promise); + try { + // Don't trigger events on the editable if the click was done outside of + // the editable itself or on something else than an element. + if (this._mousedownInEditable && ev.target instanceof Element) { + // When the users clicks in the DOM, the range is set in the next + // tick. The observation of the resulting range must thus be delayed + // to the next tick as well. Store the data we have now before it + // gets invalidated by the redrawing of the DOM. + this._initialCaretPosition = this._getEventCaretPosition(ev); + + this._pointerSelectionTimeout = new Timeout(() => { + return this._analyzeSelectionChange(ev); + }); + this._triggerEventBatch(this._pointerSelectionTimeout.promise); + } + } catch (e) { + this._mousedownInEditable = false; + this._initialCaretPosition = undefined; } } /** diff --git a/packages/core/src/JWEditor.ts b/packages/core/src/JWEditor.ts index 1318f1870..fe15dc957 100644 --- a/packages/core/src/JWEditor.ts +++ b/packages/core/src/JWEditor.ts @@ -289,7 +289,7 @@ export class JWEditor { commandName: C, params?: CommandParams, ): Promise { - await this.dispatcher.dispatch(commandName, params); + return await this.dispatcher.dispatch(commandName, params); } /** diff --git a/packages/plugin-char/src/Char.ts b/packages/plugin-char/src/Char.ts index 54a79d3cf..1d6749fac 100644 --- a/packages/plugin-char/src/Char.ts +++ b/packages/plugin-char/src/Char.ts @@ -10,11 +10,16 @@ import { Loadables } from '../../core/src/JWEditor'; import { Parser } from '../../plugin-parser/src/Parser'; import { Renderer } from '../../plugin-renderer/src/Renderer'; import { setStyles } from '../../utils/src/utils'; +import { Point, RelativePosition, VNode } from '../../core/src/VNodes/VNode'; export interface InsertTextParams extends CommandParams { text: string; formats?: Formats; } +export interface InsertHtmlParams extends CommandParams { + rangePoint: Point; + html: string; +} export class Char extends JWPlugin { static dependencies = [Inline]; @@ -26,6 +31,9 @@ export class Char extends JWPlugin insertText: { handler: this.insertText, }, + insertHtml: { + handler: this.insertHtml, + }, }; //-------------------------------------------------------------------------- @@ -62,4 +70,31 @@ export class Char extends JWPlugin }); inline.resetCache(); } + async insertHtml(params: InsertHtmlParams): Promise { + const parser = this.editor.plugins.get(Parser); + const domParser = parser && parser.engines.dom; + if (!domParser) { + // TODO: remove this when the editor can be instantiated on + // something else than DOM. + throw new Error(`No DOM parser installed.`); + } + const div = document.createElement('div'); + div.innerHTML = params.html; + const parsedEditable = await domParser.parse(div); + // const range = params.context.range; + const newNode = parsedEditable[0].children()[0]; + const [node, position] = params.rangePoint; + switch (position) { + case RelativePosition.BEFORE: + node.before(newNode); + break; + case RelativePosition.AFTER: + node.after(newNode); + break; + case RelativePosition.INSIDE: + node.append(newNode); + break; + } + return newNode; + } } diff --git a/packages/plugin-dom/src/Dom.ts b/packages/plugin-dom/src/Dom.ts index 3b8c02885..4de554525 100644 --- a/packages/plugin-dom/src/Dom.ts +++ b/packages/plugin-dom/src/Dom.ts @@ -15,14 +15,24 @@ import { Renderer } from '../../plugin-renderer/src/Renderer'; interface DomConfig extends JWPluginConfig { autoFocus?: boolean; target?: HTMLElement; + afterRender?: Function; } const defaultConfiguration = { autoFocus: false, }; - +export interface DomHook { + [key: string]: Function; + beforeRenderInEditable?: Function; +} export class Dom extends JWPlugin { static dependencies = [Parser, Renderer]; + + // protected _domHooks: Record = {}; + // readonly loaders = { + // domHooks: this._loadHooks, + // }; + readonly loadables: Loadables = { parsingEngines: [DomParsingEngine], renderingEngines: [DomRenderingEngine], @@ -34,6 +44,16 @@ export class Dom extends JWPlugin { domMap = new DomMap(); editable: HTMLElement; + // _loadHooks(hooks: DomHook[]): void { + // for (const hook of hooks) { + // for (const [hookName, fn] of Object.entries(hook)) { + // if (!this._domHooks[hookName]) { + // this._domHooks[hookName] = []; + // } + // this._domHooks[hookName].push(fn); + // } + // } + // } async start(): Promise { const target = this.configuration.target; @@ -263,6 +283,7 @@ export class Dom extends JWPlugin { } async _renderInEditable(): Promise { + // console.log('beforerender'); this.editable.innerHTML = ''; this.domMap.set(this.editor.vDocument.root, this.editable); @@ -275,6 +296,8 @@ export class Dom extends JWPlugin { } this.renderSelection(this.editor.selection, this.editable); } + this.configuration.afterRender?.(this.editable); + // console.log('afterrender'); } async _generateDomMap(): Promise { diff --git a/packages/plugin-odoo-snippets/src/OdooBindings.ts b/packages/plugin-odoo-snippets/src/OdooBindings.ts new file mode 100644 index 000000000..9592e3f86 --- /dev/null +++ b/packages/plugin-odoo-snippets/src/OdooBindings.ts @@ -0,0 +1,157 @@ +import JWEditor from '../../core/src/JWEditor'; +import { Dom } from '../../plugin-dom/src/Dom'; +import { VElement } from '../../core/src/VNodes/VElement'; +import { + RemoveClassParams, + AddClassParams, + ToggleClassParams, + OdooSnippet, + SetAttributeParams, +} from './OdooSnippet'; +import { stringify } from 'querystring'; +import { SetStyleParams, MoveParams, RemoveParams } from './OdooSnippet'; +import { InsertHtmlParams, Char } from '../../plugin-char/src/Char'; +import { Point, RelativePosition } from '../../core/src/VNodes/VNode'; + +export function createExecCommandHelper( + editor: JWEditor, + methodName: string, + paramOrder: string[], +): (...arg: any[]) => void { + return (...args: any[]) => { + const params = {}; + for (const [index, paramName] of paramOrder.entries()) { + params[paramName] = args[index]; + } + editor.execCommand(methodName, params); + }; +} + +interface ExectCommandHelpers { + [key: string]: (...args: any[]) => void; +} + +export type HtmlPoint = [Node, RelativePosition]; + +export function createExecCommandHelpersForOdoo(editor: JWEditor): ExectCommandHelpers { + const commands = { + 'removeClasses': ['nodes', 'classes'], + 'toggleClass': ['nodes', 'class', 'set'], + 'setAttribute': ['nodes', 'attributeName', 'attributeValue'], + 'setCss': ['nodes', 'cssProperty', 'cssValue'], + }; + const helpers: ExectCommandHelpers = {}; + for (const commandId in commands) { + helpers[commandId] = createExecCommandHelper(editor, commandId, commands[commandId]); + } + return helpers; +} + +export function createExecCommandHelpersForOdoo2(editor: JWEditor): ExectCommandHelpers { + const dom = editor.plugins.get(Dom); + function _getVElements(node: Node): VElement[] { + const vnodes = dom.domMap.fromDom(node); + for (const vnode of vnodes) { + if (!(vnode instanceof VElement)) { + throw new Error('VNode is not a VElement'); + } + } + return vnodes as VElement[]; + } + // function _getVNode(node: Node): VElement[] { + // const vnodes = dom.domMap.fromDom(node); + // for (const vnode of vnodes) { + // if (!(vnode instanceof VElement)) { + // throw new Error('VNode is not a VElement'); + // } + // } + // return vnodes as VElement[]; + // } + const odooCommands = { + async addClasses(node: Node, classes: string[]): Promise { + const params: AddClassParams = { + elements: _getVElements(node), + classes, + }; + await editor.execCommand('addClasses', params); + }, + async removeClasses(node: Node, classes: string[]): Promise { + const params: RemoveClassParams = { + elements: _getVElements(node), + classes, + }; + await editor.execCommand('removeClasses', params); + }, + async toggleClass(node: Node, klass: string, set?: boolean): Promise { + const params: ToggleClassParams = { + elements: _getVElements(node), + class: klass, + set, + }; + await editor.execCommand('toggleClass', params); + }, + async setAttribute( + node: Node, + attributeName: string, + attributeValue: string, + ): Promise { + const params: SetAttributeParams = { + elements: _getVElements(node), + attributeName, + attributeValue, + }; + await editor.execCommand('setAttribute', params); + }, + async setStyle( + node: Node, + property: string, + value: string, + important = false, + ): Promise { + const params: SetStyleParams = { + elements: _getVElements(node), + property, + value, + important, + }; + await editor.execCommand('setStyle', params); + }, + // todo: sometimes i need to append. Because the implementation of the insertHTML append + // it when the RelativePosition is 'inside', it work. But this is unclear that it will + // append. + // Change the API to have a clearer distinction and allowing to append/prepend; + async insertHtml(rangePoint: HtmlPoint, html: string): Promise { + const vnode = dom.domMap.fromDom(rangePoint[0])[0]; + const params: InsertHtmlParams = { + rangePoint: [vnode, rangePoint[1]], + html, + }; + return await editor.execCommand('insertHtml', params); + }, + async moveBefore(fromNode: Node, toNode: Node): Promise { + const fromVNode = dom.domMap.fromDom(fromNode)[0]; + const toVNode = dom.domMap.fromDom(toNode)[0]; + const params: MoveParams = { + from: fromVNode, + to: [toVNode, RelativePosition.BEFORE], + }; + return await editor.execCommand('move', params); + }, + async moveAfter(fromNode: Node, toNode: Node): Promise { + const fromVNode = dom.domMap.fromDom(fromNode)[0]; + const toVNode = dom.domMap.fromDom(toNode)[0]; + const params: MoveParams = { + from: fromVNode, + to: [toVNode, RelativePosition.AFTER], + }; + return await editor.execCommand('move', params); + }, + async remove(node: Node): Promise { + const params: RemoveParams = { + vnodes: dom.domMap.fromDom(node), + }; + return await editor.execCommand('remove', params); + }, + }; + return odooCommands; +} diff --git a/packages/plugin-odoo-snippets/src/OdooSnippet.ts b/packages/plugin-odoo-snippets/src/OdooSnippet.ts new file mode 100644 index 000000000..d899453a1 --- /dev/null +++ b/packages/plugin-odoo-snippets/src/OdooSnippet.ts @@ -0,0 +1,252 @@ +import { JWPlugin, JWPluginConfig } from '../../core/src/JWPlugin'; +import { Loadables } from '../../core/src/JWEditor'; +import { Parser } from '../../plugin-parser/src/Parser'; +import { Renderer } from '../../plugin-renderer/src/Renderer'; +import { Keymap } from '../../plugin-keymap/src/Keymap'; +import { Dom } from '../../plugin-dom/src/Dom'; +import { VElement } from '../../core/src/VNodes/VElement'; +import { VNode, Point, RelativePosition } from '../../core/src/VNodes/VNode'; +import JWEditor from '../../core/src/JWEditor'; + +export interface RemoveClassParams { + elements: VElement[]; + classes: string[]; +} +export interface AddClassParams { + elements: VElement[]; + classes: string[]; +} +export interface ToggleClassParams { + elements: VElement[]; + class: string; + set?: boolean; +} +export interface SetAttributeParams { + elements: VElement[]; + attributeName: string; + attributeValue: string; +} +export interface SetStyleParams { + /** + * The `VElement`s that we want to change the style + */ + elements: VElement[]; + /** + * The css property. + */ + property: string; + /** + * The css value + */ + value: string; + important?: boolean; +} +export interface MoveParams { + from: VNode; + to: Point; +} + +export interface RemoveParams { + vnodes: VNode[]; +} +interface ExecCustomParams { + node: Node; + callback: Function; +} +// type ChangeKeys = 'childList' | 'attributes' | 'characterData'; +interface MutationChange { + childList: boolean; + attributes: boolean; + characterData: boolean; +} + +async function nextTick(): Promise { + await new Promise((resolve): void => { + setTimeout(resolve); + }); +} + +export class OdooSnippet extends JWPlugin { + readonly loadables: Loadables = { + parsers: [], + renderers: [], + shortcuts: [], + }; + commands = { + removeClasses: { + handler: this.removeClasses.bind(this), + }, + addClasses: { + handler: this.addClasses.bind(this), + }, + toggleClass: { + handler: this.toggleClass.bind(this), + }, + setAttribute: { + handler: this.setAttribute.bind(this), + }, + setStyle: { + handler: this.setStyle.bind(this), + }, + move: { + handler: this.move.bind(this), + }, + remove: { + handler: this.remove.bind(this), + }, + // for later + execCustom: { + handler: this.execCustom.bind(this), + }, + }; + + constructor(public editor: JWEditor, public configuration: T) { + super(editor, configuration); + } + + async execCustom(params: ExecCustomParams): Promise { + const config: MutationObserverInit = { attributes: true, childList: true, subtree: true }; + + const changes = new Map(); + const setChange = (node: Node, type: MutationRecordType): void => { + if (!changes.has(node)) { + changes.set(node, { + childList: false, + attributes: false, + characterData: false, + }); + } + const nodeChanges = changes.get(node); + nodeChanges[type] = true; + }; + // Callback function to execute when mutations are observed + const callback: MutationCallback = function(mutationsList, observer) { + // Use traditional 'for loops' for IE 11 + for (const mutation of mutationsList) { + setChange(mutation.target, mutation.type); + if (mutation.type === 'childList') { + // mutation. + console.log('A child node has been added or removed.'); + } else if (mutation.type === 'attributes') { + console.log('mutation attributes'); + console.log('The ' + mutation.attributeName + ' attribute was modified.'); + } else if (mutation.type === 'characterData') { + console.log('A character added or removed.'); + } + } + }; + + const observer = new MutationObserver(callback); + + observer.observe(params.node, config); + params.callback(); + await nextTick(); + observer.disconnect(); + + console.log('change', changes); + for (const [node, changeTypes] of [...changes]) { + const vNode = this._getVNode(node); + if (!vNode || !(node instanceof HTMLElement)) return; + if (!(vNode instanceof VElement)) return; + if (changeTypes.attributes) { + console.log('changedAttribute node:', node); + vNode.attributes = {}; + for (const attribute of node.attributes) { + vNode.attributes[attribute.name] = attribute.value; + } + } + // if (changeTypes.childList) {} + } + } + + removeClasses(params: RemoveClassParams): void { + for (const element of params.elements) { + const classes = this._getClasses(element); + params.classes.forEach(klass => classes.delete(klass)); + this._setClasses(element, classes); + } + } + addClasses(params: AddClassParams): void { + for (const element of params.elements) { + const classes = this._getClasses(element); + params.classes.forEach(klass => classes.add(klass)); + this._setClasses(element, classes); + } + } + toggleClass(params: ToggleClassParams): void { + for (const element of params.elements) { + const classes = this._getClasses(element); + const value = typeof params.set === undefined ? params.set : !classes.has(params.class); + if (value) { + classes.add(params.class); + } else { + classes.delete(params.class); + } + this._setClasses(element, classes); + } + } + setAttribute(params: SetAttributeParams): void { + console.log('set atributes of elements', params.elements); + for (const element of params.elements) { + element.attributes[params.attributeName] = params.attributeValue; + } + } + setStyle(params: SetStyleParams): void { + for (const element of params.elements) { + const styles = this._getStyles(element); + styles[params.property] = params.important + ? params.value + ' !important' + : params.value; + this._setStyles(element, styles); + } + } + move(params: MoveParams): void { + switch (params.to[1]) { + case RelativePosition.AFTER: + params.from.after(params.to[0]); + break; + case RelativePosition.BEFORE: + params.from.before(params.to[0]); + break; + case RelativePosition.INSIDE: + params.from.append(params.to[0]); + break; + } + } + remove(params: RemoveParams): void { + for (const vnode of params.vnodes) { + vnode.remove(); + } + } + + _getVNode(domNode: Node): VNode { + const dom = this.editor.plugins.get(Dom); + return dom.domMap.fromDom(domNode)?.[0]; + } + _getClasses(vElement: VElement): Set { + const classes = vElement.attributes.class as string; + return new Set(classes.split(' ')); + } + _setClasses(vElement: VElement, classes: Set): void { + vElement.attributes.class = [...classes].join(' '); + } + _getStyles(vElement: VElement): Record { + const styles = (vElement.attributes.style as string) || ''; + return styles.split(';').reduce((acc, cssRule) => { + const [prop, value] = cssRule.split(':'); + if (!prop || !value) return acc; + acc[prop] = value; + return acc; + }, {}); + } + _setStyles(vElement: VElement, styles: Record): void { + const stylesString = Object.entries(styles) + .map(rule => rule.join(':')) + .join(';'); + vElement.attributes.style = stylesString; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- +} diff --git a/packages/plugin-odoo-snippets/src/TableCellDomParser.ts b/packages/plugin-odoo-snippets/src/TableCellDomParser.ts new file mode 100644 index 000000000..7ca712ae6 --- /dev/null +++ b/packages/plugin-odoo-snippets/src/TableCellDomParser.ts @@ -0,0 +1,34 @@ +import { AbstractParser } from '../../plugin-parser/src/AbstractParser'; +import { DomParsingEngine } from '../../plugin-dom/src/DomParsingEngine'; +import { TableCellNode } from './TableCellNode'; +import { nodeName } from '../../utils/src/utils'; + +export class SnippetDomParser extends AbstractParser { + static id = 'dom'; + engine: DomParsingEngine; + protected _itemProcessed = new Set(); + + predicate = (item: Node): boolean => { + return false; + // if (!this._itemProcessed.has(item)) { + // this._itemProcessed.add(item); + // return true; + // } else { + // return false; + // } + }; + + /** + * Parse a table cell node. + * + * @param item + */ + async parse(item: HTMLTableCellElement): Promise { + const cell = new TableCellNode(nodeName(item) === 'TH'); + cell.attributes = this.engine.parseAttributes(item); + + const children = await this.engine.parse(...item.childNodes); + cell.append(...children); + return [cell]; + } +} diff --git a/packages/plugin-odoo-snippets/src/TableCellDomRenderer.ts b/packages/plugin-odoo-snippets/src/TableCellDomRenderer.ts new file mode 100644 index 000000000..7aaf9bbc6 --- /dev/null +++ b/packages/plugin-odoo-snippets/src/TableCellDomRenderer.ts @@ -0,0 +1,45 @@ +import { AbstractRenderer } from '../../plugin-renderer/src/AbstractRenderer'; +import { TableCellNode } from './TableCellNode'; +import { DomRenderingEngine } from '../../plugin-dom/src/DomRenderingEngine'; + +export class TableCellDomRenderer extends AbstractRenderer { + static id = 'dom'; + engine: DomRenderingEngine; + predicate = TableCellNode; + + /** + * Render the TableCellNode along with its contents. + * + * @param cell + */ + async render(cell: TableCellNode): Promise { + // If the cell is not active, do not render it (it means it is + // represented by its manager cell's colspan or rowspan: it was merged). + if (!cell.isActive()) return []; + + // Render the cell and its contents. + const td = document.createElement(cell.header ? 'th' : 'td'); + const renderedChildren = await this.renderChildren(cell); + for (const renderedChild of renderedChildren) { + for (const domChild of renderedChild) { + td.append(domChild); + } + } + + // Render attributes. + // Colspan and rowspan are handled differently from other attributes: + // they are automatically calculated in function of the cell's managed + // cells. Render them here. If their value is 1 or less, they are + // insignificant so no need to render them. + const attributes = { ...cell.attributes }; + if (cell.colspan > 1) { + attributes.colspan = '' + cell.colspan; + } + if (cell.rowspan > 1) { + attributes.rowspan = '' + cell.rowspan; + } + this.engine.renderAttributes(attributes, td); + + return [td]; + } +} diff --git a/packages/plugin-odoo-snippets/src/TableCellNode.ts b/packages/plugin-odoo-snippets/src/TableCellNode.ts new file mode 100644 index 000000000..c69ef1ea8 --- /dev/null +++ b/packages/plugin-odoo-snippets/src/TableCellNode.ts @@ -0,0 +1,206 @@ +import { VNode } from '../../core/src/VNodes/VNode'; +import { TableNode } from './TableNode'; +import { TableRowNode } from './TableRowNode'; +import { LineBreakNode } from '../../plugin-linebreak/src/LineBreakNode'; // TODO: remove dependency + +export interface TableCellAttributes extends Record> { + colspan?: string; + rowspan?: string; +} + +export class TableCellNode extends VNode { + attributes: TableCellAttributes; + // Only the `managerCell` setter should modify the following private keys. + __managerCell: TableCellNode; + __managedCells = new Set(); + + constructor(public header = false) { + super(); + } + + //-------------------------------------------------------------------------- + // Lifecycle + //-------------------------------------------------------------------------- + + /** + * Return a new VNode with the same type and attributes as this VNode. + * + * @override + */ + clone(): this { + const clone = new this.constructor(this.header); + clone.attributes = { ...this.attributes }; + return clone; + } + + //-------------------------------------------------------------------------- + // Getters + //-------------------------------------------------------------------------- + + /** + * @override + */ + get name(): string { + let coordinatesRepr = ' <(' + this.rowIndex + ', ' + this.columnIndex + ')'; + if (this.colspan > 1 || this.rowspan > 1) { + const endRow = this.rowIndex + this.rowspan - 1; + const endColumn = this.columnIndex + this.colspan - 1; + coordinatesRepr += ':(' + endRow + ', ' + endColumn + ')'; + } + coordinatesRepr += '>'; + return ( + super.name + + coordinatesRepr + + (this.header ? ': header' : '') + + (this.isActive() ? '' : ' (inactive)') + ); + } + /** + * Return the cell that manages this cell, if any. + */ + get managerCell(): TableCellNode { + return this.__managerCell; + } + /** + * Return the set of cells that this cell manages. + */ + get managedCells(): Set { + return new Set(this.__managedCells); + } + /** + * Return the computed column span of this cell, in function of its managed + * cells. + */ + get colspan(): number { + const cellsArray = Array.from(this.managedCells); + const sameRowCells = cellsArray.filter(cell => cell.rowIndex === this.rowIndex); + return 1 + sameRowCells.length; + } + /** + * Return the computed row span of this cell, in function of its managed + * cells. + */ + get rowspan(): number { + const cellsArray = Array.from(this.managedCells); + const sameColumnCells = cellsArray.filter(cell => cell.columnIndex === this.columnIndex); + return 1 + sameColumnCells.length; + } + /** + * Return the row to which this cell belongs. + */ + get row(): TableCellNode[] { + return this.ancestor(TableRowNode).children(TableCellNode); + } + /** + * Return the column to which this cell belongs, as an array of cells. + */ + get column(): TableCellNode[] { + return this.ancestor(TableNode).columns[this.columnIndex]; + } + /** + * Return the index of the row to which this cell belongs. + */ + get rowIndex(): number { + return this.ancestor(TableRowNode).rowIndex; + } + /** + * Return the index of the column to which this cell belongs. + */ + get columnIndex(): number { + return this.parent.children(TableCellNode).indexOf(this); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Return true if this cell is active (ie not managed by another cell). + */ + isActive(): boolean { + return !this.managerCell; + } + /** + * Set the given cell as manager of this cell. + * Note: A cell managed by another cell also copies its manager's attributes + * and properties and hands over its children to its manager. + * + * @override + */ + mergeWith(newManager: VNode): void { + const thisTable = this.ancestor(TableNode); + const otherTable = newManager.ancestor(TableNode); + if (!newManager.is(TableCellNode) || thisTable !== otherTable) return; + + this.__managerCell = newManager; + newManager.manage(this); + } + /** + * Unmerge this cell from its manager. + */ + unmerge(): void { + const manager = this.__managerCell; + if (manager) { + this.__managerCell = null; + // If we just removed this cell's manager, also remove this cell + // from the old manager's managed cells. + manager.unmanage(this); + } + } + /** + * Set the given cell as managed by this cell. + * Note: A cell managed by another cell also copies its manager's attributes + * and properties and hands over its children to its manager. + * + * @param cell + */ + manage(cell: TableCellNode): void { + this.__managedCells.add(cell); + + // Copy the manager's attributes and properties. + cell.attributes = { ...this.attributes }; + cell.header = this.header; + + // Move the children to the manager. + if (cell.hasChildren()) { + this.append(new LineBreakNode()); + } + this.append(...cell.children); + + // Hand the managed cells over to the manager. + for (const managedCell of cell.managedCells) { + managedCell.mergeWith(this); + cell.unmanage(managedCell); + } + + // Copy the manager's row if an entire row was merged + const row = cell.ancestor(TableRowNode); + if (row) { + const cells = row.children(TableCellNode); + const rowIsMerged = cells.every(rowCell => rowCell.managerCell === this); + if (rowIsMerged) { + const managerRow = cell.managerCell.ancestor(TableRowNode); + row.header = managerRow.header; + row.attributes = { ...managerRow.attributes }; + } + } + + // Ensure reciprocity. + if (cell.managerCell !== this) { + cell.mergeWith(this); + } + } + /** + * Restore the independence of the given cell. + * + * @param cell + */ + unmanage(cell: TableCellNode): void { + this.__managedCells.delete(cell); + + // Ensure reciprocity. + if (cell.managerCell === this) { + cell.unmerge(); + } + } +} diff --git a/packages/plugin-odoo-snippets/src/TableDomParser.ts b/packages/plugin-odoo-snippets/src/TableDomParser.ts new file mode 100644 index 000000000..62ea8cb6d --- /dev/null +++ b/packages/plugin-odoo-snippets/src/TableDomParser.ts @@ -0,0 +1,141 @@ +import { AbstractParser } from '../../plugin-parser/src/AbstractParser'; +import { DomParsingEngine } from '../../plugin-dom/src/DomParsingEngine'; +import { TableNode } from './TableNode'; +import { TableRowNode } from './TableRowNode'; +import { TableCellNode } from './TableCellNode'; +import { nodeName } from '../../utils/src/utils'; + +export class TableDomParser extends AbstractParser { + static id = 'dom'; + engine: DomParsingEngine; + + predicate = (item: Node): item is HTMLTableElement => { + return nodeName(item) === 'TABLE'; + }; + + /** + * Parse a table node. + * + * @param item + */ + async parse(item: HTMLTableElement): Promise { + // Parse the table itself and its attributes. + const table = new TableNode(); + table.attributes = this.engine.parseAttributes(item); + + // Parse the contents of the table. + const children = await this.engine.parse(...item.childNodes); + + // Build the grid. + const dimensions = this._getTableDimensions(item); + const parsedRows = children.filter(row => row.is(TableRowNode)) as TableRowNode[]; + const grid = this._createTableGrid(dimensions, parsedRows); + + // Append the cells to the rows. + const rows = new Array(dimensions[0]); + for (let rowIndex = 0; rowIndex < grid.length; rowIndex += 1) { + rows[rowIndex] = parsedRows[rowIndex]; + const cells = grid[rowIndex]; + let row = rows[rowIndex]; + if (!row) { + row = new TableRowNode(); + } + row.append(...cells); + } + + // Append the rows and other children to the table. + let rowIndex = 0; + for (let childIndex = 0; childIndex < children.length; childIndex += 1) { + const child = children[childIndex]; + if (child.is(TableRowNode)) { + const row = rows[rowIndex]; + table.append(row); + rowIndex += 1; + } else { + table.append(children[childIndex]); + } + } + return [table]; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return a tuple with the row length and the column length of the given DOM + * table element. + * + * @param domTable + */ + _getTableDimensions(domTable: HTMLTableElement): [number, number] { + const domRows = Array.from(domTable.querySelectorAll('tr')) + const domTableRows = domRows.filter(row => row.closest('table') === domTable); + let columnCount = 0; + if (domTableRows.length) { + const domCells = Array.from(domTableRows[0].querySelectorAll('td, th')); + const domTableCells = domCells.filter(cell => cell.closest('table') === domTable); + for (const domChild of domTableCells) { + columnCount += (domChild as HTMLTableCellElement).colSpan; + } + } + return [domTableRows.length, columnCount]; + } + /** + * Build and return the grid (2D array: rows of cells) that will be used to + * create the table. We want all the rows to have the same number of cells, + * and all the columns to have the same number of cells. + * + * @param dimensions + * @param rows + */ + _createTableGrid(dimensions: [number, number], rows: TableRowNode[]): TableCellNode[][] { + const [rowCount, columnCount] = dimensions; + + // Initialize the grid (2D array: rows of cells). + const grid: TableCellNode[][] = Array.from(Array(rowCount), () => new Array(columnCount)); + + // Move every parsed child row to its place in the grid, and create + // placeholder cells where there aren't any, accounting for column spans + // and row spans. + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + const row = rows[rowIndex]; + const cells = row.children(TableCellNode).slice(); + for (let domCellIndex = 0; domCellIndex < cells.length; domCellIndex += 1) { + const cell = cells[domCellIndex]; + + // If there is a cell at this grid position already, it means we + // added it there when handling another cell, ie. it's a + // placeholder cell, managed by a previously handled cell. + // The current cell needs to be added at the next available slot + // instead. + let columnIndex = domCellIndex; + while (grid[rowIndex][columnIndex]) { + columnIndex += 1; + } + + // Check traversing colspan and rowspan to insert placeholder + // cells where necessary. Consume these attributes as they will + // be replaced with getters. + const colspan = parseInt(cell.attributes.colspan) || 1; + const rowspan = parseInt(cell.attributes.rowspan) || 1; + delete cell.attributes.colspan; + delete cell.attributes.rowspan; + for (let i = rowIndex; i < rowIndex + rowspan; i += 1) { + for (let j = columnIndex; j < columnIndex + colspan; j += 1) { + if (i === rowIndex && j === columnIndex) { + // Add the current cell to the grid. + grid[i][j] = cell; + } else { + // Add a placeholder cell to the grid. + const placeholderCell = new TableCellNode(); + placeholderCell.mergeWith(cell); + grid[i][j] = placeholderCell; + } + } + } + } + } + return grid; + } +} diff --git a/packages/plugin-odoo-snippets/src/TableDomRenderer.ts b/packages/plugin-odoo-snippets/src/TableDomRenderer.ts new file mode 100644 index 000000000..874141897 --- /dev/null +++ b/packages/plugin-odoo-snippets/src/TableDomRenderer.ts @@ -0,0 +1,56 @@ +import { AbstractRenderer } from '../../plugin-renderer/src/AbstractRenderer'; +import { TableNode } from './TableNode'; +import { TableRowNode } from './TableRowNode'; +import { DomRenderingEngine } from '../../plugin-dom/src/DomRenderingEngine'; +import { nodeName } from '../../utils/src/utils'; + +export class TableDomRenderer extends AbstractRenderer { + static id = 'dom'; + engine: DomRenderingEngine; + predicate = TableNode; + + /** + * Render the TableNode along with its contents (TableRowNodes). + */ + async render(table: TableNode): Promise { + const domTable = document.createElement('table'); + const domHead = document.createElement('thead'); + let domBody = document.createElement('tbody'); + + for (const child of table.children()) { + const domChild = await this.renderChild(child); + if (child.is(TableRowNode)) { + // If the child is a row, append it to its containing section. + const tableSection = child.header ? domHead : domBody; + tableSection.append(...domChild); + this.engine.renderAttributes( + child.attributes['table-section-attributes'] as Record, + tableSection, + ); + if (!tableSection.parentNode) { + domTable.append(tableSection); + } + } else { + domTable.append(...domChild); + // Create a new so the rest of the rows, if any, get + // appended to it, after this element. + domBody = document.createElement('tbody'); + } + } + this.engine.renderAttributes(table.attributes, domTable); + return [domTable]; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return true if the given item is a table section element. + * + * @param item + */ + _isTableSection(item: Node): item is HTMLTableSectionElement { + return nodeName(item) === 'THEAD' || nodeName(item) === 'TBODY'; + } +} diff --git a/packages/plugin-odoo-snippets/src/TableNode.ts b/packages/plugin-odoo-snippets/src/TableNode.ts new file mode 100644 index 000000000..bb872676a --- /dev/null +++ b/packages/plugin-odoo-snippets/src/TableNode.ts @@ -0,0 +1,192 @@ +import { VElement } from '../../core/src/VNodes/VElement'; +import { TableRowNode } from './TableRowNode'; +import { TableCellNode } from './TableCellNode'; + +export class TableNode extends VElement { + constructor() { + super('TABLE'); + } + + //-------------------------------------------------------------------------- + // Getters + //-------------------------------------------------------------------------- + + /** + * @override + */ + get name(): string { + return super.name + ': ' + this.rowCount + 'x' + this.columnCount; + } + /** + * Return an array of rows in this table, as arrays of cells. + */ + get rows(): TableCellNode[][] { + return this.children(TableRowNode).map(row => row.children(TableCellNode)); + } + /** + * Return an array of columns in this table, as arrays of cells. + */ + get columns(): TableCellNode[][] { + const columns = new Array(this.columnCount).fill(undefined); + return columns.map((_, columnIndex) => + this.children(TableRowNode).map(row => row.children(TableCellNode)[columnIndex]), + ); + } + /** + * Return the number of rows in this table. + */ + get rowCount(): number { + return this.children(TableRowNode).length; + } + /** + * Return the number of columns in this table. + */ + get columnCount(): number { + return this.firstChild(TableRowNode).children(TableCellNode).length; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Return the cell of this table that can be found at the given coordinates, + * if any. + * + * @param rowIndex + * @param columnIndex + */ + getCellAt(rowIndex: number, columnIndex: number): TableCellNode { + return this.children(TableRowNode)[rowIndex].children(TableCellNode)[columnIndex]; + } + /** + * Add a new row above the reference row (the row of the given reference + * cell). Copy the styles and colspans of the cells of the reference row. If + * the reference row traverses a rowspan, extend that rowspan. + * + * @param referenceCell + */ + addRowAbove(referenceCell: TableCellNode): void { + const referenceRow = referenceCell.ancestor(TableRowNode); + const newRow = referenceRow.clone(); + referenceRow.before(newRow); + for (const cell of referenceRow.children(TableCellNode)) { + const clone = cell.clone(); + newRow.append(clone); + + // Handle managers. + const manager = cell.managerCell; + if (manager) { + if (manager.rowIndex === referenceRow.rowIndex) { + // If the current cell's manager is in the reference row, + // the clone's manager should be that manager's clone. + const managerClone = this.getCellAt(newRow.rowIndex, manager.columnIndex); + clone.mergeWith(managerClone); + } else { + clone.mergeWith(manager); + } + } + } + } + /** + * Add a new row below the reference row (the row of the given reference + * cell). Copy the styles and colspans of the cells of the reference row. If + * the reference row traverses a rowspan, extend that rowspan. + * Note: a rowspan ending at the reference cell is not extended. + * + * @param referenceCell + */ + addRowBelow(referenceCell: TableCellNode): void { + const rowIndex = referenceCell.rowIndex + referenceCell.rowspan - 1; + const referenceRow = this.children(TableRowNode)[rowIndex]; + const newRow = referenceRow.clone(); + referenceRow.after(newRow); + for (const cell of referenceRow.children(TableCellNode)) { + const clone = cell.clone(); + newRow.append(clone); + + // Handle managers. + if (cell.managerCell) { + const manager = cell.managerCell; + const managerEndRow = manager.rowIndex + manager.rowspan - 1; + if (managerEndRow === rowIndex && manager.columnIndex !== cell.columnIndex) { + // Take the new row equivalent of the above cell's manager + // (copy colspan). + clone.mergeWith(this.getCellAt(newRow.rowIndex, manager.columnIndex)); + } else if (managerEndRow !== rowIndex) { + // Take the manager cell of the above cell (extend rowspan), + // only if said manager's rowspan is not ending with the + // above cell. + clone.mergeWith(manager); + } + } else if (cell.rowspan > 1) { + // If the cell has a rowspan, extend it. + clone.mergeWith(cell); + } + } + } + /** + * Add a new column before the reference column (the column of the given + * reference cell). Copy the styles and rowspans of the cells of the + * reference column. If the reference column traverses a colspan, extend + * that colspan. + * + * @param referenceCell + */ + addColumnBefore(referenceCell: TableCellNode): void { + const referenceColumn = referenceCell.column; + for (const cell of referenceColumn) { + const clone = cell.clone(); + cell.before(clone); + + // Handle managers. + const manager = cell.managerCell; + if (manager) { + if (manager.columnIndex === referenceCell.columnIndex) { + // If the current cell's manager is in the reference column, + // the clone's manager should be that manager's clone. + const managerClone = this.getCellAt(manager.rowIndex, clone.columnIndex); + clone.mergeWith(managerClone); + } else { + clone.mergeWith(manager); + } + } + } + } + /** + * Add a new column after the reference column (the column of the given + * reference cell). Copy the styles and rowspans of the cells of the + * reference column. If the reference column traverses a colpan, extend that + * colspan. + * Note: a colspan ending at the reference cell is not extended. + * + * @param referenceCell + */ + addColumnAfter(referenceCell: TableCellNode): void { + const columnIndex = referenceCell.columnIndex + referenceCell.colspan - 1; + const referenceColumn = this.columns[columnIndex]; + for (const cell of referenceColumn) { + const clone = cell.clone(); + cell.after(clone); + + // Handle managers. + if (cell.managerCell) { + const manager = cell.managerCell; + const managerEndColumn = manager.columnIndex + manager.colspan - 1; + if (managerEndColumn === columnIndex && manager.rowIndex !== cell.rowIndex) { + // Take the new column equivalent of the previous cell's + // manager (copy rowspan). + clone.mergeWith(this.getCellAt(manager.rowIndex, clone.columnIndex)); + } else if (managerEndColumn !== columnIndex) { + // Take the manager cell of the previous cell (extend + // colspan), only if said manager's colspan is not ending + // with the previous cell. + clone.mergeWith(manager); + } + } else if (cell.colspan > 1) { + // If the cell has a colspan, extend it. + clone.mergeWith(cell); + } + } + } +} diff --git a/packages/plugin-odoo-snippets/src/TableRowDomParser.ts b/packages/plugin-odoo-snippets/src/TableRowDomParser.ts new file mode 100644 index 000000000..4d85386b5 --- /dev/null +++ b/packages/plugin-odoo-snippets/src/TableRowDomParser.ts @@ -0,0 +1,72 @@ +import { AbstractParser } from '../../plugin-parser/src/AbstractParser'; +import { DomParsingEngine } from '../../plugin-dom/src/DomParsingEngine'; +import { TableRowNode } from './TableRowNode'; +import { nodeName } from '../../utils/src/utils'; + +export class TableRowDomParser extends AbstractParser { + static id = 'dom'; + engine: DomParsingEngine; + + predicate = (item: Node): item is Element => { + return nodeName(item) === 'THEAD' || nodeName(item) === 'TBODY' || nodeName(item) === 'TR'; + }; + + /** + * Parse a row node or a table section node. + * + * @param item + */ + async parse(item: Element): Promise { + if (this._isTableSection(item)) { + return this.parseTableSection(item); + } else if (nodeName(item) === 'TR') { + const row = new TableRowNode(); + row.attributes = this.engine.parseAttributes(item); + const cells = await this.engine.parse(...item.childNodes); + row.append(...cells); + return [row]; + } + } + /** + * Parse a or a into an array of table rows with their + * `header` property set in function of whether they are contained in a + * or a . + * + * @param tableSection + */ + async parseTableSection(tableSection: HTMLTableSectionElement): Promise { + const parsedNodes = []; + + // Parse the section's children. + for (const child of tableSection.childNodes) { + parsedNodes.push(...(await this.engine.parse(child))); + } + + // Parse the or 's attributes into a technical key of the + // node's attributes, that will be read only by `TableRowDomRenderer`. + const containerAttributes = this.engine.parseAttributes(tableSection); + + // Apply the attributes and `header` property of the container to each + // row. + for (const parsedNode of parsedNodes) { + if (parsedNode.is(TableRowNode)) { + parsedNode.header = nodeName(tableSection) === 'THEAD'; + parsedNode.attributes['table-section-attributes'] = containerAttributes; + } + } + return parsedNodes; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return true if the given item is a table section element. + * + * @param item + */ + _isTableSection(item: Node): item is HTMLTableSectionElement { + return nodeName(item) === 'THEAD' || nodeName(item) === 'TBODY'; + } +} diff --git a/packages/plugin-odoo-snippets/src/TableRowDomRenderer.ts b/packages/plugin-odoo-snippets/src/TableRowDomRenderer.ts new file mode 100644 index 000000000..1b494653e --- /dev/null +++ b/packages/plugin-odoo-snippets/src/TableRowDomRenderer.ts @@ -0,0 +1,24 @@ +import { AbstractRenderer } from '../../plugin-renderer/src/AbstractRenderer'; +import { TableRowNode } from './TableRowNode'; +import { DomRenderingEngine } from '../../plugin-dom/src/DomRenderingEngine'; + +export class TableRowDomRenderer extends AbstractRenderer { + static id = 'dom'; + engine: DomRenderingEngine; + predicate = TableRowNode; + + /** + * Render the TableRowNode along with its contents. + */ + async render(row: TableRowNode): Promise { + const domRow = document.createElement('tr'); + this.engine.renderAttributes(row.attributes, domRow); + const renderedChildren = await this.renderChildren(row); + for (const renderedChild of renderedChildren) { + for (const domChild of renderedChild) { + domRow.appendChild(domChild); + } + } + return [domRow]; + } +} diff --git a/packages/plugin-odoo-snippets/src/TableRowNode.ts b/packages/plugin-odoo-snippets/src/TableRowNode.ts new file mode 100644 index 000000000..b2a7b088f --- /dev/null +++ b/packages/plugin-odoo-snippets/src/TableRowNode.ts @@ -0,0 +1,44 @@ +import { VElement } from '../../core/src/VNodes/VElement'; +import { TableNode } from './TableNode'; + +export class TableRowNode extends VElement { + header: boolean; + constructor(header = false) { + super('TR'); + this.header = header; + } + + //-------------------------------------------------------------------------- + // Lifecycle + //-------------------------------------------------------------------------- + + /** + * Return a new VNode with the same type and attributes as this VNode. + * + * @override + */ + clone(): this { + const clone = new this.constructor(this.header); + clone.attributes = { ...this.attributes }; + return clone; + } + + //-------------------------------------------------------------------------- + // Getters + //-------------------------------------------------------------------------- + + /** + * @override + */ + get name(): string { + return super.name + (this.header ? ': header' : ''); + } + /** + * Return the index of this row in the table. + */ + get rowIndex(): number { + return this.ancestor(TableNode) + .children(TableRowNode) + .indexOf(this); + } +} diff --git a/packages/plugin-parser/src/Parser.ts b/packages/plugin-parser/src/Parser.ts index a6856e857..219092a85 100644 --- a/packages/plugin-parser/src/Parser.ts +++ b/packages/plugin-parser/src/Parser.ts @@ -1,5 +1,6 @@ import { JWPlugin, JWPluginConfig } from '../../core/src/JWPlugin'; import { ParsingEngine, ParserConstructor, ParsingEngineConstructor } from './ParsingEngine'; +import { VNode } from '../../core/src/VNodes/VNode'; export class Parser extends JWPlugin { readonly engines: Record = {}; @@ -8,6 +9,15 @@ export class Parser extends JWPlugin< parsers: this.loadParsers, }; + async parse(parsingId: string, elem: E): Promise { + const engine = this.engines[parsingId]; + if (!engine) { + // The caller might want to fallback on another rendering. + return; + } + return engine.parse(elem); + } + loadParsingEngines(parsingEngines: ParsingEngineConstructor[]): void { for (const EngineClass of parsingEngines) { const id = EngineClass.id; diff --git a/src/builder/builder-odoo.ts b/src/builder/builder-odoo.ts new file mode 100644 index 000000000..89d7f2f46 --- /dev/null +++ b/src/builder/builder-odoo.ts @@ -0,0 +1,25 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const BUILDED_PATH = path.resolve(__dirname, '../../build/odoo/build-full.js'); +const NEW_BUILD_PATH = path.resolve(__dirname, '../../build/odoo/build-full-odoo.js'); + +fs.readFile(BUILDED_PATH, 'utf8', (err, file) => { + if (err) { + throw err; + } + const newFile = ` +odoo.define('web_editor.jabberwock', function (require) { + // 'use strict'; + + ${file} + + return JWEditor; +}); + `; + fs.writeFile(NEW_BUILD_PATH, newFile, err => { + if (err) { + throw err; + } + }); +}); diff --git a/test/core/JWEditor.test.ts b/test/core/JWEditor.test.ts new file mode 100644 index 000000000..419a165d1 --- /dev/null +++ b/test/core/JWEditor.test.ts @@ -0,0 +1,69 @@ +// import { expect } from 'chai'; +// import JWEditor from '../../packages/core/src/JWEditor'; + +// function createElementAndAppend(): HTMLElement { +// const element = document.createElement('div'); +// element.innerHTML = ` +//

Hello world!

+// `; +// document.body.appendChild(element); +// return element; +// } + +// describe('core', () => { +// describe('JWEditor', () => { +// let element; +// beforeEach(() => { +// element = createElementAndAppend(); +// }); +// afterEach(() => { +// element.remove(); +// }); +// it.skip('init and start without argument', () => {}); +// it.skip('init and start with text as value', () => {}); +// it.skip('init and start with html as value', () => {}); +// it('init and start with node inside the dom', () => { +// const editor = new JWEditor(element); +// editor.start(); +// expect(element.parentElement).to.equal(document.body); +// expect(editor.el.parentElement).to.equal(document.body); +// editor.stop(); +// expect(editor.el.parentElement).to.be.null; + +// // try again to check if multiples call generate inconsistencies +// editor.start(); +// expect(element.parentElement).to.equal(document.body); +// expect(editor.el.parentElement).to.equal(document.body); +// editor.stop(); +// expect(editor.el.parentElement).to.be.null; +// }); +// it.skip('init and start with node outside the dom', () => {}); +// }); +// describe('getValue', () => { +// it('should properly get the value once edited', () => { +// const element = createElementAndAppend(); +// const editor = new JWEditor(element); +// editor.start(); +// // todo: simulate keyboard eventns +// expect(editor.getValue()).to.equal('

Hello world!

'); +// editor.stop(); +// element.remove(); +// }); +// }); +// describe('setValue', () => { +// it('should properly set the value', () => { +// const element = createElementAndAppend(); +// const element2 = document.createElement('p'); +// element2.innerHTML = 'This is a paragraph'; + +// const editor = new JWEditor(element); +// editor.start(); +// editor.setValue(element2); +// expect(editor.getValue()).to.equal(element2.outerHTML); +// // debugger; +// editor.stop(); +// element.remove(); +// element2.remove(); +// }); +// }); +// }); diff --git a/webpack.config.js b/webpack.config.js index 13aa9e293..e17add94f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,6 +11,7 @@ const entries = glob.sync(__dirname + '/examples/**/*.ts').reduce((acc, file) => acc[fileKey] = file; return acc; }, {}); +entries['odoo-integration'] = './packages/build-odoo-integration/odoo-integration.ts'; module.exports = { mode: 'development', @@ -19,7 +20,9 @@ module.exports = { output: { path: path.resolve(__dirname, 'build/examples'), filename: '[name].js', + library: 'JWEditor', }, + module: { rules: [ { @@ -53,6 +56,11 @@ module.exports = { // Webpack check that the host is `0.0.0.0` by default. Disable that // check to access the server from external devices. disableHostCheck: true, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', + }, }, // We might require this configuration in the future to // bundle the differents parts of the editor.