diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationItem.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationItem.ts new file mode 100644 index 00000000..dfcc92cd --- /dev/null +++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationItem.ts @@ -0,0 +1,37 @@ +export class AnnotationItem { + private decoration!: any + + constructor(decoration: any) { + this.decoration = decoration + } + + get id() { + return this.decoration.type.spec.id + } + + get from() { + return this.decoration.from + } + + get to() { + return this.decoration.to + } + + get data() { + return this.decoration.type.spec.data + } + + get HTMLAttributes() { + return this.decoration.type.attrs + } + + toString() { + return JSON.stringify({ + id: this.id, + data: this.data, + from: this.from, + to: this.to, + HTMLAttributes: this.HTMLAttributes, + }) + } +} diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationPlugin.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationPlugin.ts new file mode 100644 index 00000000..14ac17e3 --- /dev/null +++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationPlugin.ts @@ -0,0 +1,50 @@ +import * as Y from 'yjs' +import { Plugin, PluginKey } from 'prosemirror-state' +import { AnnotationState } from './AnnotationState' + +export const AnnotationPluginKey = new PluginKey('annotation') + +export interface AnnotationPluginOptions { + HTMLAttributes: { + [key: string]: any + }, + onUpdate: (items: [any?]) => {}, + map: Y.Map, + instance: string, +} + +export const AnnotationPlugin = (options: AnnotationPluginOptions) => new Plugin({ + key: AnnotationPluginKey, + + state: { + init() { + return new AnnotationState({ + HTMLAttributes: options.HTMLAttributes, + map: options.map, + instance: options.instance, + }) + }, + apply(transaction, pluginState, oldState, newState) { + return pluginState.apply(transaction, newState) + }, + }, + + props: { + decorations(state) { + const { decorations } = this.getState(state) + const { selection } = state + + if (!selection.empty) { + return decorations + } + + const annotations = this + .getState(state) + .annotationsAt(selection.from) + + options.onUpdate(annotations) + + return decorations + }, + }, +}) diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationState.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationState.ts new file mode 100644 index 00000000..01625480 --- /dev/null +++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationState.ts @@ -0,0 +1,151 @@ +import * as Y from 'yjs' +import { EditorState, Transaction } from 'prosemirror-state' +import { Decoration, DecorationSet } from 'prosemirror-view' +import { ySyncPluginKey, relativePositionToAbsolutePosition, absolutePositionToRelativePosition } from 'y-prosemirror' +import { AddAnnotationAction, DeleteAnnotationAction, UpdateAnnotationAction } from './collaboration-annotation' +import { AnnotationPluginKey } from './AnnotationPlugin' +import { AnnotationItem } from './AnnotationItem' + +export interface AnnotationStateOptions { + HTMLAttributes: { + [key: string]: any + }, + map: Y.Map, + instance: string, +} + +export class AnnotationState { + options: AnnotationStateOptions + + decorations = DecorationSet.empty + + constructor(options: AnnotationStateOptions) { + this.options = options + } + + randomId() { + // TODO: That seems … to simple. + return Math.floor(Math.random() * 0xffffffff).toString() + } + + findAnnotation(id: string) { + const current = this.decorations.find() + + for (let i = 0; i < current.length; i += 1) { + if (current[i].spec.id === id) { + return current[i] + } + } + } + + addAnnotation(action: AddAnnotationAction, state: EditorState) { + const ystate = ySyncPluginKey.getState(state) + const { type, binding } = ystate + const { map } = this.options + const { from, to, data } = action + const absoluteFrom = absolutePositionToRelativePosition(from, type, binding.mapping) + const absoluteTo = absolutePositionToRelativePosition(to, type, binding.mapping) + + map.set(this.randomId(), { + from: absoluteFrom, + to: absoluteTo, + data, + }) + } + + updateAnnotation(action: UpdateAnnotationAction) { + const { map } = this.options + + const annotation = map.get(action.id) + + map.set(action.id, { + from: annotation.from, + to: annotation.to, + data: action.data, + }) + } + + deleteAnnotation(id: string) { + const { map } = this.options + + map.delete(id) + } + + annotationsAt(position: number) { + return this.decorations.find(position, position).map(decoration => { + return new AnnotationItem(decoration) + }) + } + + createDecorations(state: EditorState) { + const { map, HTMLAttributes } = this.options + const ystate = ySyncPluginKey.getState(state) + const { doc, type, binding } = ystate + const decorations: Decoration[] = [] + + map.forEach((annotation, id) => { + const from = relativePositionToAbsolutePosition(doc, type, annotation.from, binding.mapping) + const to = relativePositionToAbsolutePosition(doc, type, annotation.to, binding.mapping) + + if (!from || !to) { + return + } + + console.log(`[${this.options.instance}] Decoration.inline()`, from, to, HTMLAttributes, { id, data: annotation.data }) + + if (from === to) { + console.warn(`[${this.options.instance}] corrupt decoration `, annotation.from, from, annotation.to, to) + } + + decorations.push( + Decoration.inline(from, to, HTMLAttributes, { id, data: annotation.data, inclusiveEnd: true }), + ) + }) + + this.decorations = DecorationSet.create(state.doc, decorations) + } + + apply(transaction: Transaction, state: EditorState) { + // Add/Remove annotations + const action = transaction.getMeta(AnnotationPluginKey) as AddAnnotationAction | UpdateAnnotationAction | DeleteAnnotationAction + + if (action && action.type) { + console.log(`[${this.options.instance}] action: ${action.type}`) + + if (action.type === 'addAnnotation') { + this.addAnnotation(action, state) + } + + if (action.type === 'updateAnnotation') { + this.updateAnnotation(action) + } + + if (action.type === 'deleteAnnotation') { + this.deleteAnnotation(action.id) + } + + // @ts-ignore + if (action.type === 'createDecorations') { + this.createDecorations(state) + } + + return this + } + + // Use Y.js to update positions + const ystate = ySyncPluginKey.getState(state) + + if (ystate.isChangeOrigin) { + console.log(`[${this.options.instance}] isChangeOrigin: true → createDecorations`) + this.createDecorations(state) + + return this + } + + // Use ProseMirror to update positions + console.log(`[${this.options.instance}] isChangeOrigin: false → ProseMirror mapping`) + this.decorations = this.decorations.map(transaction.mapping, transaction.doc) + + return this + } +} diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/collaboration-annotation.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/collaboration-annotation.ts new file mode 100644 index 00000000..9c5c7a20 --- /dev/null +++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/collaboration-annotation.ts @@ -0,0 +1,146 @@ +import * as Y from 'yjs' +import { Extension } from '@tiptap/core' +import { AnnotationPlugin, AnnotationPluginKey } from './AnnotationPlugin' + +export interface AddAnnotationAction { + type: 'addAnnotation', + data: any, + from: number, + to: number, +} + +export interface UpdateAnnotationAction { + type: 'updateAnnotation', + id: string, + data: any, +} + +export interface DeleteAnnotationAction { + type: 'deleteAnnotation', + id: string, +} + +export interface AnnotationOptions { + HTMLAttributes: { + [key: string]: any + }, + /** + * An event listener which receives annotations for the current selection. + */ + onUpdate: (items: [any?]) => {}, + /** + * An initialized Y.js document. + */ + document: Y.Doc | null, + /** + * Name of a Y.js map, can be changed to sync multiple fields with one Y.js document. + */ + field: string, + /** + * A raw Y.js map, can be used instead of `document` and `field`. + */ + map: Y.Map | null, + instance: string, +} + +function getMapFromOptions(options: AnnotationOptions): Y.Map { + return options.map + ? options.map + : options.document?.getMap(options.field) as Y.Map +} + +declare module '@tiptap/core' { + interface Commands { + annotation: { + addAnnotation: (data: any) => ReturnType, + updateAnnotation: (id: string, data: any) => ReturnType, + deleteAnnotation: (id: string) => ReturnType, + } + } +} + +export const CollaborationAnnotation = Extension.create({ + name: 'annotation', + + priority: 1000, + + defaultOptions: { + HTMLAttributes: { + class: 'annotation', + }, + onUpdate: decorations => decorations, + document: null, + field: 'annotations', + map: null, + instance: '', + }, + + onCreate() { + const map = getMapFromOptions(this.options) + + map.observe(() => { + console.log(`[${this.options.instance}] map updated → createDecorations`) + + const transaction = this.editor.state.tr.setMeta(AnnotationPluginKey, { + type: 'createDecorations', + }) + + this.editor.view.dispatch(transaction) + }) + }, + + addCommands() { + return { + addAnnotation: (data: any) => ({ dispatch, state }) => { + const { selection } = state + + if (selection.empty) { + return false + } + + if (dispatch && data) { + state.tr.setMeta(AnnotationPluginKey, { + type: 'addAnnotation', + from: selection.from, + to: selection.to, + data, + }) + } + + return true + }, + updateAnnotation: (id: string, data: any) => ({ dispatch, state }) => { + if (dispatch) { + state.tr.setMeta(AnnotationPluginKey, { + type: 'updateAnnotation', + id, + data, + }) + } + + return true + }, + deleteAnnotation: id => ({ dispatch, state }) => { + if (dispatch) { + state.tr.setMeta(AnnotationPluginKey, { + type: 'deleteAnnotation', + id, + }) + } + + return true + }, + } + }, + + addProseMirrorPlugins() { + return [ + AnnotationPlugin({ + HTMLAttributes: this.options.HTMLAttributes, + onUpdate: this.options.onUpdate, + map: getMapFromOptions(this.options), + instance: this.options.instance, + }), + ] + }, +}) diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/index.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/index.ts new file mode 100644 index 00000000..b64dc6ea --- /dev/null +++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/index.ts @@ -0,0 +1,5 @@ +import { CollaborationAnnotation } from './collaboration-annotation' + +export * from './collaboration-annotation' + +export default CollaborationAnnotation diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/index.html b/demos/src/Experiments/CollaborationAnnotation/Vue/index.html new file mode 100644 index 00000000..5e7b561c --- /dev/null +++ b/demos/src/Experiments/CollaborationAnnotation/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/index.spec.js b/demos/src/Experiments/CollaborationAnnotation/Vue/index.spec.js new file mode 100644 index 00000000..f1b0a3ae --- /dev/null +++ b/demos/src/Experiments/CollaborationAnnotation/Vue/index.spec.js @@ -0,0 +1,7 @@ +context('/demos/Experiments/Annotation', () => { + before(() => { + cy.visit('/demos/Experiments/Annotation') + }) + + // TODO: Write tests +}) diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/index.vue b/demos/src/Experiments/CollaborationAnnotation/Vue/index.vue new file mode 100644 index 00000000..eb380fc6 --- /dev/null +++ b/demos/src/Experiments/CollaborationAnnotation/Vue/index.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/demos/src/Experiments/Commands/Vue/CommandsList.vue b/demos/src/Experiments/Commands/Vue/CommandsList.vue new file mode 100644 index 00000000..3c72c13f --- /dev/null +++ b/demos/src/Experiments/Commands/Vue/CommandsList.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/demos/src/Experiments/Commands/Vue/commands.js b/demos/src/Experiments/Commands/Vue/commands.js new file mode 100644 index 00000000..53aac19e --- /dev/null +++ b/demos/src/Experiments/Commands/Vue/commands.js @@ -0,0 +1,25 @@ +import { Extension } from '@tiptap/core' +import Suggestion from '@tiptap/suggestion' + +export default Extension.create({ + name: 'mention', + + defaultOptions: { + suggestion: { + char: '/', + startOfLine: false, + command: ({ editor, range, props }) => { + props.command({ editor, range }) + }, + }, + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) diff --git a/demos/src/Experiments/Commands/Vue/index.html b/demos/src/Experiments/Commands/Vue/index.html new file mode 100644 index 00000000..9d24f87d --- /dev/null +++ b/demos/src/Experiments/Commands/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/Commands/Vue/index.vue b/demos/src/Experiments/Commands/Vue/index.vue new file mode 100644 index 00000000..a46ae9ab --- /dev/null +++ b/demos/src/Experiments/Commands/Vue/index.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/demos/src/Experiments/Details/Vue/details-summary.ts b/demos/src/Experiments/Details/Vue/details-summary.ts new file mode 100644 index 00000000..9b4b403d --- /dev/null +++ b/demos/src/Experiments/Details/Vue/details-summary.ts @@ -0,0 +1,33 @@ +import { Node } from '@tiptap/core' + +export interface DetailsSummaryOptions { + HTMLAttributes: { + [key: string]: any + }, +} + +export default Node.create({ + name: 'detailsSummary', + + content: 'text*', + + marks: '', + + group: 'block', + + isolating: true, + + defaultOptions: { + HTMLAttributes: {}, + }, + + parseHTML() { + return [{ + tag: 'summary', + }] + }, + + renderHTML() { + return ['summary', 0] + }, +}) diff --git a/demos/src/Experiments/Details/Vue/details.ts b/demos/src/Experiments/Details/Vue/details.ts new file mode 100644 index 00000000..bf43ccbc --- /dev/null +++ b/demos/src/Experiments/Details/Vue/details.ts @@ -0,0 +1,107 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export interface DetailsOptions { + HTMLAttributes: { + [key: string]: any + }, +} + +declare module '@tiptap/core' { + interface Commands { + details: { + /** + * Set a details node + */ + setDetails: () => ReturnType, + /** + * Toggle a details node + */ + toggleDetails: () => ReturnType, + /** + * Unset a details node + */ + unsetDetails: () => ReturnType, + } + } +} + +export default Node.create({ + name: 'details', + + content: 'detailsSummary block+', + + group: 'block', + + // defining: true, + + defaultOptions: { + HTMLAttributes: {}, + }, + + parseHTML() { + return [ + { + tag: 'details', + }, + { + tag: 'div[data-type="details"]', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['details', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + addNodeView() { + return ({ HTMLAttributes }) => { + const item = document.createElement('div') + item.setAttribute('data-type', 'details') + + const toggle = document.createElement('div') + toggle.setAttribute('data-type', 'detailsToggle') + item.append(toggle) + + const content = document.createElement('div') + content.setAttribute('data-type', 'detailsContent') + item.append(content) + + toggle.addEventListener('click', () => { + if (item.hasAttribute('open')) { + item.removeAttribute('open') + } else { + item.setAttribute('open', 'open') + } + }) + + Object.entries(HTMLAttributes).forEach(([key, value]) => { + item.setAttribute(key, value) + }) + + return { + dom: item, + contentDOM: content, + ignoreMutation: (mutation: MutationRecord) => { + return !item.contains(mutation.target) || item === mutation.target + }, + } + } + }, + + addCommands() { + return { + setDetails: () => ({ commands }) => { + // TODO: Doesn’t work + return commands.wrapIn('details') + }, + toggleDetails: () => ({ commands }) => { + // TODO: Doesn’t work + return commands.toggleWrap('details') + }, + unsetDetails: () => ({ commands }) => { + // TODO: Doesn’t work + return commands.lift('details') + }, + } + }, +}) diff --git a/demos/src/Experiments/Details/Vue/index.html b/demos/src/Experiments/Details/Vue/index.html new file mode 100644 index 00000000..0b54ad18 --- /dev/null +++ b/demos/src/Experiments/Details/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/Details/Vue/index.vue b/demos/src/Experiments/Details/Vue/index.vue new file mode 100644 index 00000000..591b45f2 --- /dev/null +++ b/demos/src/Experiments/Details/Vue/index.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/demos/src/Experiments/Embeds/Vue/iframe.ts b/demos/src/Experiments/Embeds/Vue/iframe.ts new file mode 100644 index 00000000..a57ac904 --- /dev/null +++ b/demos/src/Experiments/Embeds/Vue/iframe.ts @@ -0,0 +1,78 @@ +import { Node } from '@tiptap/core' + +export interface IframeOptions { + allowFullscreen: boolean, + HTMLAttributes: { + [key: string]: any + }, +} + +declare module '@tiptap/core' { + interface Commands { + iframe: { + /** + * Add an iframe + */ + setIframe: (options: { src: string }) => ReturnType, + } + } +} + +export default Node.create({ + name: 'iframe', + + group: 'block', + + atom: true, + + defaultOptions: { + allowFullscreen: true, + HTMLAttributes: { + class: 'iframe-wrapper', + }, + }, + + addAttributes() { + return { + src: { + default: null, + }, + frameborder: { + default: 0, + }, + allowfullscreen: { + default: this.options.allowFullscreen, + parseHTML: () => { + return { + allowfullscreen: this.options.allowFullscreen, + } + }, + }, + } + }, + + parseHTML() { + return [{ + tag: 'iframe', + }] + }, + + renderHTML({ HTMLAttributes }) { + return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]] + }, + + addCommands() { + return { + setIframe: (options: { src: string }) => ({ tr, dispatch }) => { + const { selection } = tr + const node = this.type.create(options) + + if (dispatch) { + tr.replaceRangeWith(selection.from, selection.to, node) + } + + return true + }, + } + }, +}) diff --git a/demos/src/Experiments/Embeds/Vue/index.html b/demos/src/Experiments/Embeds/Vue/index.html new file mode 100644 index 00000000..ca834ee9 --- /dev/null +++ b/demos/src/Experiments/Embeds/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/Embeds/Vue/index.vue b/demos/src/Experiments/Embeds/Vue/index.vue new file mode 100644 index 00000000..3ccb236e --- /dev/null +++ b/demos/src/Experiments/Embeds/Vue/index.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/demos/src/Experiments/Figure/Vue/figure.ts b/demos/src/Experiments/Figure/Vue/figure.ts new file mode 100644 index 00000000..74c29e60 --- /dev/null +++ b/demos/src/Experiments/Figure/Vue/figure.ts @@ -0,0 +1,200 @@ +import { + Node, + nodeInputRule, + mergeAttributes, + findChildrenInRange, + Tracker, +} from '@tiptap/core' + +export interface FigureOptions { + HTMLAttributes: Record, +} + +declare module '@tiptap/core' { + interface Commands { + figure: { + /** + * Add a figure element + */ + setFigure: (options: { + src: string, + alt?: string, + title?: string, + caption?: string, + }) => ReturnType, + + /** + * Converts an image to a figure + */ + imageToFigure: () => ReturnType, + + /** + * Converts a figure to an image + */ + figureToImage: () => ReturnType, + } + } +} + +export const inputRegex = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/ + +export const Figure = Node.create({ + name: 'figure', + + defaultOptions: { + HTMLAttributes: {}, + }, + + group: 'block', + + content: 'inline*', + + draggable: true, + + isolating: true, + + addAttributes() { + return { + src: { + default: null, + parseHTML: element => { + return { + src: element.querySelector('img')?.getAttribute('src'), + } + }, + }, + + alt: { + default: null, + parseHTML: element => { + return { + alt: element.querySelector('img')?.getAttribute('alt'), + } + }, + }, + + title: { + default: null, + parseHTML: element => { + return { + title: element.querySelector('img')?.getAttribute('title'), + } + }, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'figure', + contentElement: 'figcaption', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'figure', this.options.HTMLAttributes, + ['img', mergeAttributes(HTMLAttributes, { draggable: false, contenteditable: false })], + ['figcaption', 0], + ] + }, + + addCommands() { + return { + setFigure: ({ caption, ...attrs }) => ({ chain }) => { + return chain() + .insertContent({ + type: this.name, + attrs, + content: caption + ? [{ type: 'text', text: caption }] + : [], + }) + // set cursor at end of caption field + .command(({ tr, commands }) => { + const { doc, selection } = tr + const position = doc.resolve(selection.to - 2).end() + + return commands.setTextSelection(position) + }) + .run() + }, + + imageToFigure: () => ({ tr, commands }) => { + const { doc, selection } = tr + const { from, to } = selection + const images = findChildrenInRange(doc, { from, to }, node => node.type.name === 'image') + + if (!images.length) { + return false + } + + const tracker = new Tracker(tr) + + return commands.forEach(images, ({ node, pos }) => { + const mapResult = tracker.map(pos) + + if (mapResult.deleted) { + return false + } + + const range = { + from: mapResult.position, + to: mapResult.position + node.nodeSize, + } + + return commands.insertContentAt(range, { + type: this.name, + attrs: { + src: node.attrs.src, + }, + }) + }) + }, + + figureToImage: () => ({ tr, commands }) => { + const { doc, selection } = tr + const { from, to } = selection + const figures = findChildrenInRange(doc, { from, to }, node => node.type.name === this.name) + + if (!figures.length) { + return false + } + + const tracker = new Tracker(tr) + + return commands.forEach(figures, ({ node, pos }) => { + const mapResult = tracker.map(pos) + + if (mapResult.deleted) { + return false + } + + const range = { + from: mapResult.position, + to: mapResult.position + node.nodeSize, + } + + return commands.insertContentAt(range, { + type: 'image', + attrs: { + src: node.attrs.src, + }, + }) + }) + }, + } + }, + + addInputRules() { + return [ + nodeInputRule(inputRegex, this.type, match => { + const [, alt, src, title] = match + + return { src, alt, title } + }), + ] + }, +}) diff --git a/demos/src/Experiments/Figure/Vue/index.html b/demos/src/Experiments/Figure/Vue/index.html new file mode 100644 index 00000000..b7970ed8 --- /dev/null +++ b/demos/src/Experiments/Figure/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/Figure/Vue/index.vue b/demos/src/Experiments/Figure/Vue/index.vue new file mode 100644 index 00000000..e049a8ba --- /dev/null +++ b/demos/src/Experiments/Figure/Vue/index.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/demos/src/Experiments/GenericFigure/Vue/figcaption.ts b/demos/src/Experiments/GenericFigure/Vue/figcaption.ts new file mode 100644 index 00000000..dd9fb473 --- /dev/null +++ b/demos/src/Experiments/GenericFigure/Vue/figcaption.ts @@ -0,0 +1,27 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export const Figcaption = Node.create({ + name: 'figcaption', + + defaultOptions: { + HTMLAttributes: {}, + }, + + content: 'inline*', + + selectable: false, + + draggable: false, + + parseHTML() { + return [ + { + tag: 'figcaption', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['figcaption', mergeAttributes(HTMLAttributes), 0] + }, +}) diff --git a/demos/src/Experiments/GenericFigure/Vue/figure.ts b/demos/src/Experiments/GenericFigure/Vue/figure.ts new file mode 100644 index 00000000..198c4933 --- /dev/null +++ b/demos/src/Experiments/GenericFigure/Vue/figure.ts @@ -0,0 +1,56 @@ +import { Node, mergeAttributes } from '@tiptap/core' +import { Plugin } from 'prosemirror-state' + +export const Figure = Node.create({ + name: 'figure', + + defaultOptions: { + HTMLAttributes: {}, + }, + + group: 'block', + + content: 'block figcaption', + + draggable: true, + + isolating: true, + + parseHTML() { + return [ + { + tag: `figure[data-type="${this.name}"]`, + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['figure', mergeAttributes(HTMLAttributes, { 'data-type': this.name }), 0] + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + handleDOMEvents: { + // prevent dragging nodes out of the figure + dragstart: (view, event) => { + if (!event.target) { + return false + } + + const pos = view.posAtDOM(event.target as HTMLElement, 0) + const $pos = view.state.doc.resolve(pos) + + if ($pos.parent.type === this.type) { + event.preventDefault() + } + + return false + }, + }, + }, + }), + ] + }, +}) diff --git a/demos/src/Experiments/GenericFigure/Vue/index.html b/demos/src/Experiments/GenericFigure/Vue/index.html new file mode 100644 index 00000000..fd5cefc7 --- /dev/null +++ b/demos/src/Experiments/GenericFigure/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/GenericFigure/Vue/index.vue b/demos/src/Experiments/GenericFigure/Vue/index.vue new file mode 100644 index 00000000..3492303d --- /dev/null +++ b/demos/src/Experiments/GenericFigure/Vue/index.vue @@ -0,0 +1,329 @@ + + + + + diff --git a/demos/src/Experiments/GlobalDragHandle/Vue/DragHandle.js b/demos/src/Experiments/GlobalDragHandle/Vue/DragHandle.js new file mode 100644 index 00000000..accc4a75 --- /dev/null +++ b/demos/src/Experiments/GlobalDragHandle/Vue/DragHandle.js @@ -0,0 +1,163 @@ +import { Extension } from '@tiptap/core' +import { NodeSelection, Plugin } from 'prosemirror-state' +import { serializeForClipboard } from 'prosemirror-view/src/clipboard' + +function removeNode(node) { + node.parentNode.removeChild(node) +} + +function absoluteRect(node) { + const data = node.getBoundingClientRect() + + return { + top: data.top, + left: data.left, + width: data.width, + } +} + +export default Extension.create({ + addProseMirrorPlugins() { + function blockPosAtCoords(coords, view) { + const pos = view.posAtCoords(coords) + let node = view.domAtPos(pos.pos) + + node = node.node + + while (node && node.parentNode) { + if (node.parentNode?.classList?.contains('ProseMirror')) { // todo + break + } + + node = node.parentNode + } + + if (node && node.nodeType === 1) { + const desc = view.docView.nearestDesc(node, true) + + if (!(!desc || desc === view.docView)) { + return desc.posBefore + } + } + return null + } + + function dragStart(e, view) { + view.composing = true + + if (!e.dataTransfer) { + return + } + + const coords = { left: e.clientX + 50, top: e.clientY } + const pos = blockPosAtCoords(coords, view) + + if (pos != null) { + view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos))) + + const slice = view.state.selection.content() + + // console.log({ + // from: view.nodeDOM(view.state.selection.from), + // to: view.nodeDOM(view.state.selection.to), + // }) + const { dom, text } = serializeForClipboard(view, slice) + + e.dataTransfer.clearData() + e.dataTransfer.setData('text/html', dom.innerHTML) + e.dataTransfer.setData('text/plain', text) + + const el = document.querySelector('.ProseMirror-selectednode') + e.dataTransfer?.setDragImage(el, 0, 0) + + view.dragging = { slice, move: true } + } + } + + let dropElement + const WIDTH = 28 + + return [ + new Plugin({ + view(editorView) { + const element = document.createElement('div') + + element.draggable = 'true' + element.classList.add('global-drag-handle') + element.addEventListener('dragstart', e => dragStart(e, editorView)) + dropElement = element + document.body.appendChild(dropElement) + + return { + // update(view, prevState) { + // }, + destroy() { + removeNode(dropElement) + dropElement = null + }, + } + }, + props: { + handleDrop(view, event, slice, moved) { + if (moved) { + // setTimeout(() => { + // console.log('remove selection') + // view.dispatch(view.state.tr.deleteSelection()) + // }, 50) + } + }, + // handlePaste() { + // alert(2) + // }, + handleDOMEvents: { + // drop(view, event) { + // setTimeout(() => { + // const node = document.querySelector('.ProseMirror-hideselection') + // if (node) { + // node.classList.remove('ProseMirror-hideselection') + // } + // }, 50) + // }, + mousemove(view, event) { + const coords = { + left: event.clientX + WIDTH + 50, + top: event.clientY, + } + const pos = view.posAtCoords(coords) + + if (pos) { + let node = view.domAtPos(pos?.pos) + + if (node) { + node = node.node + while (node && node.parentNode) { + if (node.parentNode?.classList?.contains('ProseMirror')) { // todo + break + } + node = node.parentNode + } + + if (node instanceof Element) { + const cstyle = window.getComputedStyle(node) + const lineHeight = parseInt(cstyle.lineHeight, 10) + // const top = parseInt(cstyle.marginTop, 10) + parseInt(cstyle.paddingTop, 10) + const top = 0 + const rect = absoluteRect(node) + const win = node.ownerDocument.defaultView + + rect.top += win.pageYOffset + ((lineHeight - 24) / 2) + top + rect.left += win.pageXOffset + rect.width = `${WIDTH}px` + + dropElement.style.left = `${-WIDTH + rect.left}px` + dropElement.style.top = `${rect.top}px` + } + } + } + }, + }, + }, + }), + ] + }, +}) diff --git a/demos/src/Experiments/GlobalDragHandle/Vue/index.html b/demos/src/Experiments/GlobalDragHandle/Vue/index.html new file mode 100644 index 00000000..14c05ca1 --- /dev/null +++ b/demos/src/Experiments/GlobalDragHandle/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/GlobalDragHandle/Vue/index.vue b/demos/src/Experiments/GlobalDragHandle/Vue/index.vue new file mode 100644 index 00000000..c5792a4d --- /dev/null +++ b/demos/src/Experiments/GlobalDragHandle/Vue/index.vue @@ -0,0 +1,133 @@ + + + + + + + diff --git a/demos/src/Experiments/Linter/Vue/extension/Linter.ts b/demos/src/Experiments/Linter/Vue/extension/Linter.ts new file mode 100644 index 00000000..ab5edc5b --- /dev/null +++ b/demos/src/Experiments/Linter/Vue/extension/Linter.ts @@ -0,0 +1,103 @@ +import { Extension } from '@tiptap/core' +import { Decoration, DecorationSet } from 'prosemirror-view' +import { Plugin, PluginKey, TextSelection } from 'prosemirror-state' +import { Node as ProsemirrorNode } from 'prosemirror-model' +import LinterPlugin, { Result as Issue } from './LinterPlugin' + +interface IconDivElement extends HTMLDivElement { + issue?: Issue +} + +function renderIcon(issue: Issue) { + const icon: IconDivElement = document.createElement('div') + + icon.className = 'lint-icon' + icon.title = issue.message + icon.issue = issue + + return icon +} + +function runAllLinterPlugins(doc: ProsemirrorNode, plugins: Array) { + const decorations: [any?] = [] + + const results = plugins.map(RegisteredLinterPlugin => { + return new RegisteredLinterPlugin(doc).scan().getResults() + }).flat() + + results.forEach(issue => { + decorations.push(Decoration.inline(issue.from, issue.to, { + class: 'problem', + }), + Decoration.widget(issue.from, renderIcon(issue))) + }) + + return DecorationSet.create(doc, decorations) +} + +export interface LinterOptions { + plugins: Array, +} + +export const Linter = Extension.create({ + name: 'linter', + + defaultOptions: { + plugins: [], + }, + + addProseMirrorPlugins() { + const { plugins } = this.options + + return [ + new Plugin({ + key: new PluginKey('linter'), + state: { + init(_, { doc }) { + return runAllLinterPlugins(doc, plugins) + }, + apply(transaction, oldState) { + return transaction.docChanged + ? runAllLinterPlugins(transaction.doc, plugins) + : oldState + }, + }, + props: { + decorations(state) { + return this.getState(state) + }, + handleClick(view, _, event) { + const target = (event.target as IconDivElement) + if (/lint-icon/.test(target.className) && target.issue) { + const { from, to } = target.issue + + view.dispatch( + view.state.tr + .setSelection(TextSelection.create(view.state.doc, from, to)) + .scrollIntoView(), + ) + + return true + } + + return false + }, + handleDoubleClick(view, _, event) { + const target = (event.target as IconDivElement) + if (/lint-icon/.test((event.target as HTMLElement).className) && target.issue) { + const prob = target.issue + + if (prob.fix) { + prob.fix(view, prob) + view.focus() + return true + } + } + + return false + }, + }, + }), + ] + }, +}) diff --git a/demos/src/Experiments/Linter/Vue/extension/LinterPlugin.ts b/demos/src/Experiments/Linter/Vue/extension/LinterPlugin.ts new file mode 100644 index 00000000..200b146b --- /dev/null +++ b/demos/src/Experiments/Linter/Vue/extension/LinterPlugin.ts @@ -0,0 +1,35 @@ +import { Node as ProsemirrorNode } from 'prosemirror-model' + +export interface Result { + message: string, + from: number, + to: number, + fix?: Function +} + +export default class LinterPlugin { + protected doc + + private results: Array = [] + + constructor(doc: ProsemirrorNode) { + this.doc = doc + } + + record(message: string, from: number, to: number, fix?: Function) { + this.results.push({ + message, + from, + to, + fix, + }) + } + + scan() { + return this + } + + getResults() { + return this.results + } +} diff --git a/demos/src/Experiments/Linter/Vue/extension/index.ts b/demos/src/Experiments/Linter/Vue/extension/index.ts new file mode 100644 index 00000000..29d42a68 --- /dev/null +++ b/demos/src/Experiments/Linter/Vue/extension/index.ts @@ -0,0 +1,8 @@ +import { Linter } from './Linter' + +export * from './Linter' +export default Linter + +export { BadWords } from './plugins/BadWords' +export { Punctuation } from './plugins/Punctuation' +export { HeadingLevel } from './plugins/HeadingLevel' diff --git a/demos/src/Experiments/Linter/Vue/extension/plugins/BadWords.ts b/demos/src/Experiments/Linter/Vue/extension/plugins/BadWords.ts new file mode 100644 index 00000000..6111b4d8 --- /dev/null +++ b/demos/src/Experiments/Linter/Vue/extension/plugins/BadWords.ts @@ -0,0 +1,25 @@ +import LinterPlugin from '../LinterPlugin' + +export class BadWords extends LinterPlugin { + + public regex = /\b(obviously|clearly|evidently|simply)\b/ig + + scan() { + this.doc.descendants((node: any, position: number) => { + if (!node.isText) { + return + } + + const matches = this.regex.exec(node.text) + + if (matches) { + this.record( + `Try not to say '${matches[0]}'`, + position + matches.index, position + matches.index + matches[0].length, + ) + } + }) + + return this + } +} diff --git a/demos/src/Experiments/Linter/Vue/extension/plugins/HeadingLevel.ts b/demos/src/Experiments/Linter/Vue/extension/plugins/HeadingLevel.ts new file mode 100644 index 00000000..001d5122 --- /dev/null +++ b/demos/src/Experiments/Linter/Vue/extension/plugins/HeadingLevel.ts @@ -0,0 +1,30 @@ +import { EditorView } from 'prosemirror-view' +import LinterPlugin, { Result as Issue } from '../LinterPlugin' + +export class HeadingLevel extends LinterPlugin { + fixHeader(level: number) { + return function ({ state, dispatch }: EditorView, issue: Issue) { + dispatch(state.tr.setNodeMarkup(issue.from - 1, undefined, { level })) + } + } + + scan() { + let lastHeadLevel: number | null = null + + this.doc.descendants((node, position) => { + if (node.type.name === 'heading') { + // Check whether heading levels fit under the current level + const { level } = node.attrs + + if (lastHeadLevel != null && level > lastHeadLevel + 1) { + this.record(`Heading too small (${level} under ${lastHeadLevel})`, + position + 1, position + 1 + node.content.size, + this.fixHeader(lastHeadLevel + 1)) + } + lastHeadLevel = level + } + }) + + return this + } +} diff --git a/demos/src/Experiments/Linter/Vue/extension/plugins/Punctuation.ts b/demos/src/Experiments/Linter/Vue/extension/plugins/Punctuation.ts new file mode 100644 index 00000000..befd7d63 --- /dev/null +++ b/demos/src/Experiments/Linter/Vue/extension/plugins/Punctuation.ts @@ -0,0 +1,37 @@ +import { EditorView } from 'prosemirror-view' +import LinterPlugin, { Result as Issue } from '../LinterPlugin' + +export class Punctuation extends LinterPlugin { + public regex = / ([,.!?:]) ?/g + + fix(replacement: any) { + return function ({ state, dispatch }: EditorView, issue: Issue) { + dispatch( + state.tr.replaceWith( + issue.from, issue.to, + state.schema.text(replacement), + ), + ) + } + } + + scan() { + this.doc.descendants((node, position) => { + if (!node.isText) return + + if (!node.text) return + + const matches = this.regex.exec(node.text) + + if (matches) { + this.record( + 'Suspicious spacing around punctuation', + position + matches.index, position + matches.index + matches[0].length, + this.fix(`${matches[1]} `), + ) + } + }) + + return this + } +} diff --git a/demos/src/Experiments/Linter/Vue/index.html b/demos/src/Experiments/Linter/Vue/index.html new file mode 100644 index 00000000..de5e0b5c --- /dev/null +++ b/demos/src/Experiments/Linter/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/Linter/Vue/index.vue b/demos/src/Experiments/Linter/Vue/index.vue new file mode 100644 index 00000000..ba8ac6d6 --- /dev/null +++ b/demos/src/Experiments/Linter/Vue/index.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/demos/src/Experiments/MultipleEditors/Vue/index.html b/demos/src/Experiments/MultipleEditors/Vue/index.html new file mode 100644 index 00000000..3b67a7f1 --- /dev/null +++ b/demos/src/Experiments/MultipleEditors/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/MultipleEditors/Vue/index.spec.js b/demos/src/Experiments/MultipleEditors/Vue/index.spec.js new file mode 100644 index 00000000..8845221b --- /dev/null +++ b/demos/src/Experiments/MultipleEditors/Vue/index.spec.js @@ -0,0 +1,7 @@ +context('/demos/Examples/MultipleEditors', () => { + before(() => { + cy.visit('/demos/Examples/MultipleEditors') + }) + + // TODO: Write tests +}) diff --git a/demos/src/Experiments/MultipleEditors/Vue/index.vue b/demos/src/Experiments/MultipleEditors/Vue/index.vue new file mode 100644 index 00000000..9e5cb9ca --- /dev/null +++ b/demos/src/Experiments/MultipleEditors/Vue/index.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/demos/src/Experiments/TrailingNode/Vue/index.html b/demos/src/Experiments/TrailingNode/Vue/index.html new file mode 100644 index 00000000..ece26712 --- /dev/null +++ b/demos/src/Experiments/TrailingNode/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Experiments/TrailingNode/Vue/index.vue b/demos/src/Experiments/TrailingNode/Vue/index.vue new file mode 100644 index 00000000..d7540bdc --- /dev/null +++ b/demos/src/Experiments/TrailingNode/Vue/index.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/demos/src/Experiments/TrailingNode/Vue/trailing-node.ts b/demos/src/Experiments/TrailingNode/Vue/trailing-node.ts new file mode 100644 index 00000000..e2c47c09 --- /dev/null +++ b/demos/src/Experiments/TrailingNode/Vue/trailing-node.ts @@ -0,0 +1,68 @@ +import { Extension } from '@tiptap/core' +import { PluginKey, Plugin } from 'prosemirror-state' + +// @ts-ignore +function nodeEqualsType({ types, node }) { + return (Array.isArray(types) && types.includes(node.type)) || node.type === types +} + +/** + * Extension based on: + * - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js + * - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts + */ + +export interface TrailingNodeOptions { + node: string, + notAfter: string[], +} + +export const TrailingNode = Extension.create({ + name: 'trailingNode', + + defaultOptions: { + node: 'paragraph', + notAfter: [ + 'paragraph', + ], + }, + + addProseMirrorPlugins() { + const plugin = new PluginKey(this.name) + const disabledNodes = Object.entries(this.editor.schema.nodes) + .map(([, value]) => value) + .filter(node => this.options.notAfter.includes(node.name)) + + return [ + new Plugin({ + key: plugin, + appendTransaction: (_, __, state) => { + const { doc, tr, schema } = state + const shouldInsertNodeAtEnd = plugin.getState(state) + const endPosition = doc.content.size + const type = schema.nodes[this.options.node] + + if (!shouldInsertNodeAtEnd) { + return + } + + return tr.insert(endPosition, type.create()) + }, + state: { + init: (_, state) => { + const lastNode = state.tr.doc.lastChild + return !nodeEqualsType({ node: lastNode, types: disabledNodes }) + }, + apply: (tr, value) => { + if (!tr.docChanged) { + return value + } + + const lastNode = tr.doc.lastChild + return !nodeEqualsType({ node: lastNode, types: disabledNodes }) + }, + }, + }), + ] + }, +})