diff --git a/packages/super-editor/src/components/ProseMirror.vue b/packages/super-editor/src/components/ProseMirror.vue index b5adbbaa95..97047017f7 100644 --- a/packages/super-editor/src/components/ProseMirror.vue +++ b/packages/super-editor/src/components/ProseMirror.vue @@ -36,7 +36,7 @@ const props = defineProps({ const editorElem = ref(null); const onCommentsLoaded = ({ comments }) => { - console.log({ comments }); + // console.log({ comments }); // Let super doc know we have comments emit('comments-loaded', comments); }; @@ -49,11 +49,13 @@ const initEditor = () => { onCommentsLoaded, }); - editor.on('transaction', ({ transaction }) => { - console.log({ transaction }); + editor.on('create', ({ editor }) => { + emit('editor-ready', props.documentId, editor); + }); + + editor.on('update', ({ editor, transaction }) => { + console.log({ editor, transaction }); }); - - emit('editor-ready', props.documentId, editor); }; onMounted(() => { diff --git a/packages/super-editor/src/core/CommandService.js b/packages/super-editor/src/core/CommandService.js new file mode 100644 index 0000000000..90753251a3 --- /dev/null +++ b/packages/super-editor/src/core/CommandService.js @@ -0,0 +1 @@ +export class CommandService {}; diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index adf8635adc..f9f7600be4 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -10,22 +10,50 @@ import { import { EditorView } from 'prosemirror-view'; import { history } from "prosemirror-history"; -import { buildKeymap } from '@core/shortcuts/buildKeymap'; -import { DocxSchema } from '@core/schema/DocxSchema'; -import { SuperConverter } from '@core/SuperConverter'; +import { buildKeymap } from './shortcuts/buildKeymap.js'; +import { DocxSchema } from './schema/DocxSchema.js'; +import { SuperConverter } from './SuperConverter.js'; import { EventEmitter } from './EventEmitter.js'; -import { initComments as initCommentsExt } from '@extensions/comments/comments'; +import { initComments } from '@extensions/Comments/comments.js'; +import { createDocument } from './helpers/createDocument.js'; +import { createStyleTag } from './utilities/createStyleTag.js'; +import { style } from './style.js'; export class Editor extends EventEmitter { + #commandService; + + extensionService; + + extensionStorage = {}; + schema; view; + #css; + options = { element: document.createElement('div'), content: '', - editorProps: {}, documentId: null, + injectCSS: true, + extensions: [], + editable: true, + editorProps: {}, + parseOptions: {}, + coreExtensionOptions: {}, + enableInputRules: true, + enablePasteRules: true, + enableCoreExtensions: true, + onBeforeCreate: () => null, + onCreate: () => null, + onUpdate: () => null, + onSelectionUpdate: () => null, + onTransaction: () => null, + onFocus: () => null, + onBlur: () => null, + onDestroy: () => null, + onContentError: ({ error }) => { throw error }, onCommentsLoaded: () => null, } @@ -33,18 +61,47 @@ export class Editor extends EventEmitter { super(); this.setOptions(options); + this.#createExtensionService(); + this.#createCommandService(); this.#createSchema(); this.#createConverter(); + + this.on('beforeCreate', this.options.onBeforeCreate); + this.emit('beforeCreate', { editor: this }); + this.on('contentError', this.options.onContentError); + this.#createView(); + this.#injectCSS() + this.on('create', this.options.onCreate); + this.on('update', this.options.onUpdate); + this.on('selectionUpdate', this.options.onSelectionUpdate); + this.on('transaction', this.options.onTransaction); + this.on('focus', this.options.onFocus); + this.on('blur', this.options.onBlur); + this.on('destroy', this.options.onDestroy); this.on('comments-loaded', this.options.onCommentsLoaded); - this.initComments(); + + this.#loadComments(); + + window.setTimeout(() => { + if (this.isDestroyed) return; + this.emit('create', { editor: this }); + }, 0); } get state() { return this.view.state; } + get store() { + return this.extensionStorage; + } + + get isEditable() { + return this.options.editable && this.view && this.view.editable; + } + get isDestroyed() { return !this.view?.docView; } @@ -53,7 +110,7 @@ export class Editor extends EventEmitter { this.options = { ...this.options, ...options, - } + }; if (!this.view || !this.state || this.isDestroyed) { return; @@ -66,31 +123,81 @@ export class Editor extends EventEmitter { this.view.updateState(this.state); } + setEditable(editable, emitUpdate = true) { + this.setOptions({ editable }); + + if (emitUpdate) { + this.emit('update', { editor: this, transaction: this.state.tr }); + } + } + + registerPlugin(plugin, handlePlugins) { + const plugins = typeof handlePlugins === 'function' + ? handlePlugins(plugin, [...this.state.plugins]) + : [...this.state.plugins, plugin]; + + const state = this.state.reconfigure({ plugins }); + + this.view.updateState(state); + } + + + unregisterPlugin(nameOrPluginKey) { + if (this.isDestroyed) { + return; + } + + const name = typeof nameOrPluginKey === 'string' + ? `${nameOrPluginKey}$` + : nameOrPluginKey.key; + + const state = this.state.reconfigure({ + plugins: this.state.plugins.filter((plugin) => !plugin.key.startsWith(name)), + }); + + this.view.updateState(state); + } + + #injectCSS() { + if (this.options.injectCSS && document) { + this.#css = createStyleTag(style); + } + } + + #createExtensionService() {} + + #createCommandService() {} + #createConverter() { this.converter = new SuperConverter({ docx: this.options.content, debug: true, }); } - - // TODO + + // Build schema from extensions? #createSchema() { this.schema = DocxSchema; } #createView() { let doc; - + try { - const docData = this.converter.getSchema(); - console.debug('\nSCHEMA', JSON.stringify(docData, null, 2), '\n'); - if (docData) { - doc = this.schema.nodeFromJSON(docData); - } else { - doc = this.schema.topNodeType.createAndFill(); - } + doc = createDocument( + this.converter, + this.schema, + this.options.parseOptions, + ); } catch (err) { console.error(err); + + this.emit('contentError', { + editor: this, + error: err, + }); + + // Here we can try to create document again. } this.view = new EditorView(this.options.element, { @@ -98,33 +205,67 @@ export class Editor extends EventEmitter { dispatchTransaction: this.#dispatchTransaction.bind(this), state: EditorState.create({ doc, - plugins: [ // TODO - history(), - buildKeymap(), - ], }), }); + + const newState = this.state.reconfigure({ + plugins: [ // Get plugins from extension service? + history(), + buildKeymap(), + ], + }); + this.view.updateState(newState); + + // Create Node Views? + + const dom = this.view.dom; + dom.editor = this; } #dispatchTransaction(transaction) { + if (this.view.isDestroyed) { + return; + } + const state = this.state.apply(transaction); + const selectionHasChanged = !this.state.selection.eq(state.selection); this.view.updateState(state); this.emit('transaction', { editor: this, transaction, }); + + if (selectionHasChanged) { + this.emit('selectionUpdate', { + editor: this, + transaction + }); + } + + if (!transaction.docChanged) { + return; + } + + this.emit('update', { + editor: this, + transaction, + }); } - initComments() { - const comments = initCommentsExt( + #loadComments() { + const comments = initComments( this.view, this.converter, this.options.documentId, ); this.emit('comments-loaded', { comments }); } - + + getJSON() { + return this.state.doc.toJSON(); + } + save() { console.debug('EDITOR CLASS SAVE - TODO', this.state.doc.toJSON()); // const converter = new SuperConverter(); @@ -133,4 +274,10 @@ export class Editor extends EventEmitter { const xml = this.converter.schemaToXml({ doc: this.state.doc.toJSON() }); console.debug('XML', xml); } + + destroy() { + this.emit('destroy'); + if (this.view) this.view.destroy(); + this.removeAllListeners(); + } } diff --git a/packages/super-editor/src/core/ExtensionService.js b/packages/super-editor/src/core/ExtensionService.js new file mode 100644 index 0000000000..8528a29b78 --- /dev/null +++ b/packages/super-editor/src/core/ExtensionService.js @@ -0,0 +1 @@ +export class ExtensionService {}; diff --git a/packages/super-editor/src/core/config/.gitkeep b/packages/super-editor/src/core/config/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/super-editor/src/core/extensions/.gitkeep b/packages/super-editor/src/core/extensions/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/super-editor/src/core/helpers/.gitkeep b/packages/super-editor/src/core/helpers/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/super-editor/src/core/helpers/createDocument.js b/packages/super-editor/src/core/helpers/createDocument.js new file mode 100644 index 0000000000..0e395b22c1 --- /dev/null +++ b/packages/super-editor/src/core/helpers/createDocument.js @@ -0,0 +1,14 @@ + +export function createDocument( + converter, + schema, + parseOptions, +) { + const documentData = converter.getSchema(); + console.debug('\nSCHEMA', JSON.stringify(documentData, null, 2), '\n'); + + if (documentData) { + return schema.nodeFromJSON(documentData); + } + return schema.topNodeType.createAndFill(); +} diff --git a/packages/super-editor/src/core/inputRules/.gitkeep b/packages/super-editor/src/core/inputRules/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/super-editor/src/core/pasteRules/.gitkeep b/packages/super-editor/src/core/pasteRules/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/super-editor/src/core/style.js b/packages/super-editor/src/core/style.js new file mode 100644 index 0000000000..0c26311ff8 --- /dev/null +++ b/packages/super-editor/src/core/style.js @@ -0,0 +1,65 @@ +// https://github.com/ProseMirror/prosemirror-view/blob/master/style/prosemirror.css +// https://github.com/ueberdosis/tiptap/blob/main/packages/core/src/style.ts + +export const style = `.ProseMirror { + position: relative; +} + +.ProseMirror { + word-wrap: break-word; + white-space: pre-wrap; + white-space: break-spaces; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ +} + +.ProseMirror pre { + white-space: pre-wrap; +} + +.ProseMirror li { + position: relative; +} + +.ProseMirror-hideselection *::selection { + background: transparent; +} + +.ProseMirror-hideselection *::-moz-selection { + background: transparent; +} + +.ProseMirror-hideselection * { + caret-color: transparent; +} + +/* See https://github.com/ProseMirror/prosemirror/issues/1421#issuecomment-1759320191 */ +.ProseMirror [draggable][contenteditable=false] { + user-select: text +} + +.ProseMirror-selectednode { + outline: 2px solid #8cf; +} + +/* Make sure li selections wrap around markers */ +li.ProseMirror-selectednode { + outline: none; +} + +li.ProseMirror-selectednode:after { + content: ""; + position: absolute; + left: -32px; + right: -2px; top: -2px; bottom: -2px; + border: 2px solid #8cf; + pointer-events: none; +} + +/* Protect against generic img rules */ +img.ProseMirror-separator { + display: inline !important; + border: none !important; + margin: 0 !important; +}`; diff --git a/packages/super-editor/src/core/utilities/createStyleTag.js b/packages/super-editor/src/core/utilities/createStyleTag.js new file mode 100644 index 0000000000..312c36ea03 --- /dev/null +++ b/packages/super-editor/src/core/utilities/createStyleTag.js @@ -0,0 +1,15 @@ +export function createStyleTag(style, suffix) { + const hrbrStyleTag = document.querySelector(`style[data-supereditor-style${suffix ? `-${suffix}` : ''}]`); + + if (hrbrStyleTag !== null) { + return hrbrStyleTag; + } + + const styleNode = document.createElement('style'); + + styleNode.setAttribute(`data-supereditor-style${suffix ? `-${suffix}` : ''}`, ''); + styleNode.innerHTML = style; + document.getElementsByTagName('head')[0].appendChild(styleNode); + + return styleNode; +}