diff --git a/webapp/package.json b/webapp/package.json index 784a50d74..52a163b37 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -13,6 +13,7 @@ "cross-spawn": "^7.0.5" }, "dependencies": { + "@floating-ui/dom": "^1.7.4", "@fortawesome/fontawesome-svg-core": "^1.2.26-2", "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-regular-svg-icons": "^5.15.2", @@ -20,7 +21,20 @@ "@fortawesome/vue-fontawesome": "^3.0.0-3", "@popperjs/core": "^2.11.8", "@primevue/themes": "^4.0.0", - "@tinymce/tinymce-vue": "^4.0.0", + "@tiptap/core": "^3.6.1", + "@tiptap/extension-color": "^3.6.1", + "@tiptap/extension-highlight": "^3.6.1", + "@tiptap/extension-image": "^3.6.1", + "@tiptap/extension-link": "^3.6.1", + "@tiptap/extension-mathematics": "^3.6.1", + "@tiptap/extension-placeholder": "^3.6.1", + "@tiptap/extension-table": "^3.6.1", + "@tiptap/extension-text-style": "^3.6.1", + "@tiptap/extension-typography": "^3.6.1", + "@tiptap/extension-underline": "^3.6.1", + "@tiptap/pm": "^3.6.1", + "@tiptap/starter-kit": "^3.6.1", + "@tiptap/vue-3": "^3.6.1", "@uppy/core": "^4.4.6", "@uppy/dashboard": "^4.3.4", "@uppy/webcam": "^4.2.0", @@ -36,14 +50,16 @@ "date-fns": "^2.29.3", "highlight.js": "^11.7.0", "js-md5": "^0.8.3", - "markdown-it": "^13.0.1", + "katex": "^0.16.22", + "markdown-it": "^14.1.0", "mermaid": "^11.10.0", "primeicons": "^7.0.0", "primevue": "^4.0.0", "process": "^0.11.10", + "prosemirror-tables": "^1.8.1", "qrcode-vue3": "^1.6.8", "serve": "^14.2.1", - "tinymce": "^5.10.9", + "turndown": "^7.2.1", "vue": "^3.2.4", "vue-qrcode-reader": "^5.5.7", "vue-router": "^4.0.0-0", diff --git a/webapp/public/index.html b/webapp/public/index.html index 527cc131b..db1582dc4 100644 --- a/webapp/public/index.html +++ b/webapp/public/index.html @@ -30,6 +30,14 @@ >
- + + + diff --git a/webapp/src/components/CellInformation.vue b/webapp/src/components/CellInformation.vue index dc09845be..1715dc011 100644 --- a/webapp/src/components/CellInformation.vue +++ b/webapp/src/components/CellInformation.vue @@ -85,10 +85,10 @@
- + >
@@ -106,7 +106,7 @@ + + diff --git a/webapp/src/components/EquipmentInformation.vue b/webapp/src/components/EquipmentInformation.vue index 23afc7c84..90b517209 100644 --- a/webapp/src/components/EquipmentInformation.vue +++ b/webapp/src/components/EquipmentInformation.vue @@ -56,7 +56,7 @@ - + import { createComputedSetterForItemField } from "@/field_utils.js"; -import TinyMceInline from "@/components/TinyMceInline"; +import TiptapInline from "@/components/TiptapInline"; import TableOfContents from "@/components/TableOfContents"; import CollectionList from "@/components/CollectionList"; import FormattedRefcode from "@/components/FormattedRefcode"; @@ -76,7 +76,7 @@ import Creators from "@/components/Creators"; export default { components: { - TinyMceInline, + TiptapInline, CollectionList, TableOfContents, FormattedRefcode, diff --git a/webapp/src/components/MermaidComponent.vue b/webapp/src/components/MermaidComponent.vue new file mode 100644 index 000000000..0a1a6b2ef --- /dev/null +++ b/webapp/src/components/MermaidComponent.vue @@ -0,0 +1,104 @@ + + + diff --git a/webapp/src/components/MermaidModal.vue b/webapp/src/components/MermaidModal.vue new file mode 100644 index 000000000..18e3519d5 --- /dev/null +++ b/webapp/src/components/MermaidModal.vue @@ -0,0 +1,145 @@ + + + diff --git a/webapp/src/components/SampleInformation.vue b/webapp/src/components/SampleInformation.vue index 25f18135c..0a9101a3d 100644 --- a/webapp/src/components/SampleInformation.vue +++ b/webapp/src/components/SampleInformation.vue @@ -43,7 +43,7 @@
- +
@@ -64,7 +64,7 @@ import ChemFormulaInput from "@/components/ChemFormulaInput"; import FormattedRefcode from "@/components/FormattedRefcode"; import ToggleableCollectionFormGroup from "@/components/ToggleableCollectionFormGroup"; import ToggleableCreatorsFormGroup from "@/components/ToggleableCreatorsFormGroup"; -import TinyMceInline from "@/components/TinyMceInline"; +import TiptapInline from "@/components/TiptapInline"; import SynthesisInformation from "@/components/SynthesisInformation"; import TableOfContents from "@/components/TableOfContents"; import ItemRelationshipVisualization from "@/components/ItemRelationshipVisualization"; @@ -72,7 +72,7 @@ import ItemRelationshipVisualization from "@/components/ItemRelationshipVisualiz export default { components: { ChemFormulaInput, - TinyMceInline, + TiptapInline, SynthesisInformation, TableOfContents, ItemRelationshipVisualization, diff --git a/webapp/src/components/StartingMaterialInformation.vue b/webapp/src/components/StartingMaterialInformation.vue index e8a3ca59f..0d1fbda4e 100644 --- a/webapp/src/components/StartingMaterialInformation.vue +++ b/webapp/src/components/StartingMaterialInformation.vue @@ -84,7 +84,7 @@ - + import { createComputedSetterForItemField } from "@/field_utils.js"; -import TinyMceInline from "@/components/TinyMceInline"; +import TiptapInline from "@/components/TiptapInline"; import ChemicalFormula from "@/components/ChemicalFormula"; import ChemFormulaInput from "@/components/ChemFormulaInput"; import TableOfContents from "@/components/TableOfContents"; @@ -118,7 +118,7 @@ export default { ChemicalFormula, ChemFormulaInput, ItemRelationshipVisualization, - TinyMceInline, + TiptapInline, ToggleableCollectionFormGroup, TableOfContents, FormattedRefcode, diff --git a/webapp/src/components/SynthesisInformation.vue b/webapp/src/components/SynthesisInformation.vue index 53625b4d1..7be139757 100644 --- a/webapp/src/components/SynthesisInformation.vue +++ b/webapp/src/components/SynthesisInformation.vue @@ -25,22 +25,22 @@ Procedure - + >
- - diff --git a/webapp/src/components/TiptapInline.vue b/webapp/src/components/TiptapInline.vue new file mode 100644 index 000000000..5f0cf0cee --- /dev/null +++ b/webapp/src/components/TiptapInline.vue @@ -0,0 +1,567 @@ + + + + + diff --git a/webapp/src/components/datablocks/DataBlockBase.vue b/webapp/src/components/datablocks/DataBlockBase.vue index c15b89616..fe6c5b5c8 100644 --- a/webapp/src/components/datablocks/DataBlockBase.vue +++ b/webapp/src/components/datablocks/DataBlockBase.vue @@ -127,7 +127,7 @@ - + @@ -143,15 +143,14 @@ import { DialogService } from "@/services/DialogService"; import { createComputedSetterForBlockField } from "@/field_utils.js"; -import TinyMceInline from "@/components/TinyMceInline"; +import TiptapInline from "@/components/TiptapInline"; import StyledBlockInfo from "@/components/StyledBlockInfo"; -import tinymce from "tinymce/tinymce"; import { deleteBlock, updateBlockFromServer } from "@/server_fetch_utils"; export default { components: { - TinyMceInline, + TiptapInline, StyledBlockInfo, }, props: { @@ -219,14 +218,6 @@ export default { }, methods: { async updateBlock() { - // check for any tinymce editors within the block. If so, trigger them - // to save so that the store is updated before sending data to the server - tinymce.editors.forEach((editor) => { - // check if editor is a child of this datablock - if (editor.bodyElement.closest(`#${this.block_id}`) && editor.isDirty()) { - editor.save(); - } - }); await updateBlockFromServer(this.item_id, this.block_id, this.block); }, async handleBokehEvent(event) { diff --git a/webapp/src/editor/extensions/CrossReferenceInputRule.js b/webapp/src/editor/extensions/CrossReferenceInputRule.js new file mode 100644 index 000000000..a659096f6 --- /dev/null +++ b/webapp/src/editor/extensions/CrossReferenceInputRule.js @@ -0,0 +1,180 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { createApp } from "vue"; +import ItemSelect from "@/components/ItemSelect.vue"; + +let suggestionApp = null; +let suggestionEl = null; +let cleanupListeners = null; + +export const CrossReferenceInputRule = Extension.create({ + name: "crossReferenceInputRule", + + addOptions() { + return { + suggestion: { + char: "@", + pluginKey: new PluginKey("crossReferenceSuggestion"), + allowSpaces: false, + startOfLine: false, + command: ({ editor, range, props }) => { + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: "crossreference", + attrs: { + itemId: props.item_id, + itemType: props.type || "samples", + name: props.name || "", + chemform: props.chemform || "", + }, + }, + { type: "text", text: " " }, + ]) + .run(); + }, + }, + }; + }, + + addProseMirrorPlugins() { + const { suggestion } = this.options; + const editor = this.editor; + return [createSuggestionPlugin(suggestion, editor)]; + }, +}); + +function createSuggestionPlugin(options, editor) { + const pluginKey = options.pluginKey; + + return new Plugin({ + key: pluginKey, + + state: { + init() { + return { active: false, range: null, query: null }; + }, + apply(tr, prev, oldState, newState) { + const { selection } = newState; + const { empty, from } = selection; + if (!empty) return { active: false, range: null, query: null }; + + const $pos = selection.$from; + const textBefore = $pos.parent.textContent.slice(0, $pos.parentOffset); + const match = textBefore.match(/@(\w*)$/); + + if (!match) { + hideSuggestions(); + return { active: false, range: null, query: null }; + } + + const query = match[1]; + const range = { from: from - match[0].length, to: from }; + return { active: true, range, query }; + }, + }, + + view() { + return { + update(view) { + const state = pluginKey.getState(view.state); + if (state?.active && state.query !== null) { + showSuggestions(view, state, options, editor); + } else { + hideSuggestions(); + } + }, + destroy() { + hideSuggestions(true); + }, + }; + }, + }); +} + +function showSuggestions(view, state, options, editor) { + if (!suggestionEl) { + suggestionEl = document.createElement("div"); + suggestionEl.className = "dropdown-menu show p-2 tiptap-suggestions"; + suggestionEl.style.position = "fixed"; + suggestionEl.style.minWidth = "350px"; + suggestionEl.style.maxWidth = "600px"; + suggestionEl.style.zIndex = 2000; + document.body.appendChild(suggestionEl); + } + + if (!suggestionApp) { + suggestionApp = createApp(ItemSelect, { + modelValue: null, + placeholder: "Search items...", + typesToQuery: ["samples", "cells", "starting_materials"], + "onUpdate:modelValue": (item) => { + const state = options.pluginKey.getState(editor.state); + if (!item || !state?.range) return; + + options.command({ editor, range: state.range, props: item }); + hideSuggestions(); + }, + }); + suggestionApp.mount(suggestionEl); + cleanupListeners = setupGlobalListeners(); + } + + reposition(view, state.range); + suggestionEl.style.display = "block"; + + const input = suggestionEl.querySelector("input"); + if (input) { + requestAnimationFrame(() => { + input.focus({ preventScroll: true }); + }); + } +} + +function reposition(view, range) { + if (!suggestionEl || !range) return; + + const coords = view.coordsAtPos(range.from); + + suggestionEl.style.left = `${coords.left}px`; + suggestionEl.style.top = `${coords.bottom}px`; +} + +function hideSuggestions(destroy = false) { + if (suggestionEl) { + suggestionEl.style.display = "none"; + if (destroy) { + suggestionEl.remove(); + suggestionEl = null; + suggestionApp = null; + if (cleanupListeners) { + cleanupListeners(); + cleanupListeners = null; + } + } + } +} + +function setupGlobalListeners() { + const onClickOutside = (event) => { + if (suggestionEl && !suggestionEl.contains(event.target)) { + hideSuggestions(); + } + }; + + const onScrollOrResize = () => { + if (suggestionEl) hideSuggestions(); + }; + + window.addEventListener("mousedown", onClickOutside); + window.addEventListener("scroll", onScrollOrResize, true); + window.addEventListener("resize", onScrollOrResize, true); + + return () => { + window.removeEventListener("mousedown", onClickOutside); + window.removeEventListener("scroll", onScrollOrResize, true); + window.removeEventListener("resize", onScrollOrResize, true); + }; +} diff --git a/webapp/src/editor/nodes/CrossReferenceNode.js b/webapp/src/editor/nodes/CrossReferenceNode.js new file mode 100644 index 000000000..86cf15731 --- /dev/null +++ b/webapp/src/editor/nodes/CrossReferenceNode.js @@ -0,0 +1,77 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { VueNodeViewRenderer } from "@tiptap/vue-3"; +import { Plugin } from "prosemirror-state"; +import CrossReferenceComponent from "@/components/CrossReferenceComponent.vue"; + +export const CrossReferenceNode = Node.create({ + name: "crossreference", + group: "inline", + inline: true, + atom: true, + + addAttributes() { + return { + itemId: { + default: null, + parseHTML: (el) => el.getAttribute("data-item-id"), + renderHTML: (attrs) => ({ "data-item-id": attrs.itemId }), + }, + itemType: { + default: "samples", + parseHTML: (el) => el.getAttribute("data-item-type"), + renderHTML: (attrs) => ({ "data-item-type": attrs.itemType }), + }, + name: { + default: "", + parseHTML: (el) => el.getAttribute("data-name"), + renderHTML: (attrs) => ({ "data-name": attrs.name }), + }, + chemform: { + default: "", + parseHTML: (el) => el.getAttribute("data-chemform"), + renderHTML: (attrs) => ({ "data-chemform": attrs.chemform }), + }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-type="crossreference"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["span", mergeAttributes(HTMLAttributes, { "data-type": "crossreference" })]; + }, + + addNodeView() { + return VueNodeViewRenderer(CrossReferenceComponent); + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + handleClick: (view, pos) => { + const { doc } = view.state; + const $pos = doc.resolve(pos); + + const node = $pos.nodeAfter || $pos.nodeBefore; + if (!node) return false; + + if (node.type.name === "crossreference") { + const { itemId } = node.attrs; + + const url = `/edit/${itemId}`; + + window.open(url, "_blank"); + event.preventDefault(); + + return true; + } + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/webapp/src/editor/nodes/MermaidNode.js b/webapp/src/editor/nodes/MermaidNode.js new file mode 100644 index 000000000..87db6b06e --- /dev/null +++ b/webapp/src/editor/nodes/MermaidNode.js @@ -0,0 +1,33 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { VueNodeViewRenderer } from "@tiptap/vue-3"; +import MermaidComponent from "@/components/MermaidComponent.vue"; + +export const MermaidNode = Node.create({ + name: "mermaid", + group: "block", + atom: true, + + addAttributes() { + return { + code: { + default: "graph TD; A[Start] --> B[End];", + }, + }; + }, + + parseHTML() { + return [ + { + tag: "div[data-type='mermaid']", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["div", mergeAttributes({ "data-type": "mermaid" }, HTMLAttributes)]; + }, + + addNodeView() { + return VueNodeViewRenderer(MermaidComponent); + }, +}); diff --git a/webapp/src/main.js b/webapp/src/main.js index 46659d92c..e7232ab8d 100644 --- a/webapp/src/main.js +++ b/webapp/src/main.js @@ -50,6 +50,17 @@ import { faInfoCircle, faPlus, faCheckCircle, + faBold, + faItalic, + faUnderline, + faStrikethrough, + faListUl, + faImage, + faTable, + faMinus, + faPalette, + faRemoveFormat, + faTrash, } from "@fortawesome/free-solid-svg-icons"; import { faPlusSquare } from "@fortawesome/free-regular-svg-icons"; import { faGithub, faOrcid } from "@fortawesome/free-brands-svg-icons"; @@ -98,32 +109,23 @@ library.add( faCopy, faInfoCircle, faCheckCircle, + faBold, + faItalic, + faUnderline, + faStrikethrough, + faListUl, + faImage, + faTable, + faMinus, + faPalette, + faRemoveFormat, + faTrash, ); -// Import TinyMCE -// eslint-disable-next-line no-unused-vars -import tinymce from "tinymce/tinymce"; - -import "tinymce/icons/default"; -import "tinymce/themes/silver"; -import "tinymce/skins/ui/oxide/skin.min.css"; -import "tinymce/skins/ui/oxide/content.min.css"; -import "tinymce/skins/content/default/content.min.css"; -import "tinymce/plugins/hr"; -import "tinymce/plugins/image"; -import "tinymce/plugins/link"; -import "tinymce/plugins/lists"; -import "tinymce/plugins/charmap"; -import "tinymce/plugins/table"; -import "tinymce/plugins/emoticons"; -import "tinymce/plugins/emoticons/js/emojis"; - // import "@uppy/vue" // import VueScrollTo from 'vue-scrollto'; -// import 'tinymce/plugins/link'; -import Editor from "@tinymce/tinymce-vue"; import store from "./store"; // css for vue-select @@ -142,7 +144,6 @@ app theme: DatalabPreset, }) .component("font-awesome-icon", FontAwesomeIcon) - .component("editor", Editor) .mount("#app"); console.log(`initializing app with global variable $API_URL = ${API_URL}`); diff --git a/webapp/src/views/CollectionPage.vue b/webapp/src/views/CollectionPage.vue index 5e8a9d7ec..42eb845c5 100644 --- a/webapp/src/views/CollectionPage.vue +++ b/webapp/src/views/CollectionPage.vue @@ -42,7 +42,6 @@ import { DialogService } from "@/services/DialogService"; import CollectionInformation from "@/components/CollectionInformation"; import { getCollectionData, saveCollection } from "@/server_fetch_utils"; import FormattedItemName from "@/components/FormattedItemName.vue"; -import tinymce from "tinymce/tinymce"; import { itemTypes } from "@/resources.js"; import { API_URL } from "@/resources.js"; import { formatDistanceToNow } from "date-fns"; @@ -129,10 +128,7 @@ export default { behavior: "smooth", }); }, - saveCollectionData() { - // trigger the mce save so that they update the store with their content - console.log("save clicked!"); - tinymce.editors.forEach((editor) => editor.save()); + async saveCollectionData() { saveCollection(this.collection_id); this.lastModified = "just now"; }, diff --git a/webapp/src/views/EditPage.vue b/webapp/src/views/EditPage.vue index d674fb2b5..642694d03 100644 --- a/webapp/src/views/EditPage.vue +++ b/webapp/src/views/EditPage.vue @@ -98,7 +98,7 @@