Skip to content

Commit 87b7064

Browse files
Add cross-referencing support
1 parent e28a85d commit 87b7064

File tree

4 files changed

+289
-0
lines changed

4 files changed

+289
-0
lines changed
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<template>
2+
<node-view-wrapper class="cross-reference-wrapper" contenteditable="false">
3+
<span
4+
v-if="isValid"
5+
class="formatted-cross-reference badge badge-light clickable"
6+
:style="{ backgroundColor: badgeColor }"
7+
@click="openEditPage"
8+
>
9+
@{{ node.attrs.itemId }}
10+
</span>
11+
<span v-else class="badge badge-secondary"> @{{ node.attrs.itemId }} </span>
12+
<span v-if="isValid && displayName">
13+
{{ displayName }}
14+
<span v-if="node.attrs.chemform">
15+
[ <ChemicalFormula :formula="node.attrs.chemform" /> ]
16+
</span>
17+
</span>
18+
</node-view-wrapper>
19+
</template>
20+
21+
<script>
22+
import { NodeViewWrapper } from "@tiptap/vue-3";
23+
import ChemicalFormula from "@/components/ChemicalFormula.vue";
24+
import { itemTypes } from "@/resources.js";
25+
26+
export default {
27+
components: {
28+
NodeViewWrapper,
29+
ChemicalFormula,
30+
},
31+
props: {
32+
node: {
33+
type: Object,
34+
required: true,
35+
},
36+
editor: {
37+
type: Object,
38+
required: true,
39+
},
40+
},
41+
data() {
42+
return {
43+
isValid: false,
44+
};
45+
},
46+
computed: {
47+
badgeColor() {
48+
const itemType = this.node.attrs.itemType || "samples";
49+
return itemTypes[itemType]?.lightColor || "LightGrey";
50+
},
51+
displayName() {
52+
return this.node.attrs.name || "";
53+
},
54+
},
55+
watch: {
56+
"node.attrs.itemId": {
57+
handler() {
58+
this.checkValidity();
59+
},
60+
immediate: true,
61+
},
62+
},
63+
methods: {
64+
async checkValidity() {
65+
if (!this.node.attrs.itemId) {
66+
this.isValid = false;
67+
return;
68+
}
69+
70+
this.isValid = true;
71+
},
72+
openEditPage() {
73+
window.open(`/edit/${this.node.attrs.itemId}`, "_blank");
74+
},
75+
},
76+
};
77+
</script>
78+
79+
<style scoped>
80+
.cross-reference-wrapper {
81+
display: inline;
82+
}
83+
84+
.formatted-cross-reference {
85+
border: 2px solid transparent;
86+
cursor: pointer;
87+
}
88+
89+
.formatted-cross-reference.clickable:hover {
90+
border: 2px solid rgba(0, 0, 0, 0.6);
91+
box-shadow: 0 0 2px gray;
92+
}
93+
</style>

webapp/src/components/TiptapInline.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ import Typography from "@tiptap/extension-typography";
9292
import { MermaidNode } from "@/editor/nodes/MermaidNode";
9393
import MermaidModal from "@/components/MermaidModal.vue";
9494
95+
import { CrossReferenceNode } from "@/editor/nodes/CrossReferenceNode";
96+
import { CrossReferenceInputRule } from "@/editor/extensions/CrossReferenceInputRule";
97+
9598
export default {
9699
components: { EditorContent, MermaidModal },
97100
@@ -365,6 +368,8 @@ export default {
365368
Highlight.configure({ multicolor: true }),
366369
Typography,
367370
MermaidNode,
371+
CrossReferenceNode,
372+
CrossReferenceInputRule,
368373
],
369374
content: this.modelValue,
370375
onUpdate: () => this.$emit("update:modelValue", this.editor.getHTML()),
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { Extension } from "@tiptap/core";
2+
import { Plugin, PluginKey } from "@tiptap/pm/state";
3+
import { createApp } from "vue";
4+
import ItemSelect from "@/components/ItemSelect.vue";
5+
6+
let suggestionApp = null;
7+
let suggestionEl = null;
8+
9+
export const CrossReferenceInputRule = Extension.create({
10+
name: "crossReferenceInputRule",
11+
12+
addOptions() {
13+
return {
14+
suggestion: {
15+
char: "@",
16+
pluginKey: new PluginKey("crossReferenceSuggestion"),
17+
allowSpaces: false,
18+
startOfLine: false,
19+
command: ({ editor, range, props }) => {
20+
editor
21+
.chain()
22+
.focus()
23+
.insertContentAt(range, [
24+
{
25+
type: "crossreference",
26+
attrs: {
27+
itemId: props.item_id,
28+
itemType: props.type || "samples",
29+
name: props.name || "",
30+
chemform: props.chemform || "",
31+
},
32+
},
33+
{ type: "text", text: " " },
34+
])
35+
.run();
36+
},
37+
},
38+
};
39+
},
40+
41+
addProseMirrorPlugins() {
42+
const { suggestion } = this.options;
43+
const editor = this.editor;
44+
return [createSuggestionPlugin(suggestion, editor)];
45+
},
46+
});
47+
48+
function createSuggestionPlugin(options, editor) {
49+
const pluginKey = options.pluginKey;
50+
51+
return new Plugin({
52+
key: pluginKey,
53+
54+
state: {
55+
init() {
56+
return { active: false, query: null, range: null };
57+
},
58+
59+
apply(tr, prev, oldState, newState) {
60+
const { selection } = newState;
61+
const { empty, from } = selection;
62+
if (!empty) return { ...prev, active: false };
63+
const $pos = selection.$from;
64+
const textBefore = $pos.parent.textContent.slice(0, $pos.parentOffset);
65+
const match = textBefore.match(/@(\w*)$/);
66+
if (!match) {
67+
hideSuggestions();
68+
return { ...prev, active: false };
69+
}
70+
const query = match[1];
71+
const range = { from: from - match[0].length, to: from };
72+
return { active: true, query, range };
73+
},
74+
},
75+
76+
view() {
77+
return {
78+
update(view) {
79+
const state = pluginKey.getState(view.state);
80+
if (state?.active && state.query !== null) {
81+
showSuggestions(view, state, options, editor);
82+
} else {
83+
hideSuggestions();
84+
}
85+
},
86+
destroy() {
87+
hideSuggestions(true);
88+
},
89+
};
90+
},
91+
});
92+
}
93+
94+
function showSuggestions(view, state, options, editor) {
95+
if (!suggestionEl) {
96+
suggestionEl = document.createElement("div");
97+
suggestionEl.className = "dropdown-menu show p-2 tiptap-suggestions";
98+
document.body.appendChild(suggestionEl);
99+
}
100+
101+
if (!suggestionApp) {
102+
suggestionApp = createApp(ItemSelect, {
103+
modelValue: null,
104+
placeholder: "Search items...",
105+
typesToQuery: ["samples", "cells", "starting_materials"],
106+
"onUpdate:modelValue": (item) => {
107+
if (!item) return;
108+
options.command({ editor, range: state.range, props: item });
109+
hideSuggestions();
110+
},
111+
});
112+
suggestionApp.mount(suggestionEl);
113+
window.addEventListener("scroll", () => hideSuggestions(), true);
114+
window.addEventListener("resize", () => hideSuggestions(), true);
115+
}
116+
117+
reposition(view, state);
118+
suggestionEl.style.display = "block";
119+
suggestionEl.querySelector("input")?.focus();
120+
}
121+
122+
function reposition(view, state) {
123+
if (!suggestionEl) return;
124+
const coords = view.coordsAtPos(state.range.from);
125+
126+
suggestionEl.style.position = "absolute";
127+
suggestionEl.style.left = `${coords.left}px`;
128+
suggestionEl.style.top = `${coords.bottom - 20}px`;
129+
suggestionEl.style.zIndex = 1050;
130+
131+
suggestionEl.style.minWidth = "350px";
132+
suggestionEl.style.maxWidth = "600px";
133+
}
134+
135+
function hideSuggestions(destroy = false) {
136+
if (suggestionEl) {
137+
suggestionEl.style.display = "none";
138+
if (destroy) {
139+
suggestionEl.remove();
140+
suggestionEl = null;
141+
suggestionApp = null;
142+
}
143+
}
144+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Node, mergeAttributes } from "@tiptap/core";
2+
import { VueNodeViewRenderer } from "@tiptap/vue-3";
3+
import CrossReferenceComponent from "@/components/CrossReferenceComponent.vue";
4+
5+
export const CrossReferenceNode = Node.create({
6+
name: "crossreference",
7+
group: "inline",
8+
inline: true,
9+
atom: true,
10+
11+
addAttributes() {
12+
return {
13+
itemId: {
14+
default: null,
15+
parseHTML: (el) => el.getAttribute("data-item-id"),
16+
renderHTML: (attrs) => ({ "data-item-id": attrs.itemId }),
17+
},
18+
itemType: {
19+
default: "samples",
20+
parseHTML: (el) => el.getAttribute("data-item-type"),
21+
renderHTML: (attrs) => ({ "data-item-type": attrs.itemType }),
22+
},
23+
name: {
24+
default: "",
25+
parseHTML: (el) => el.getAttribute("data-name"),
26+
renderHTML: (attrs) => ({ "data-name": attrs.name }),
27+
},
28+
chemform: {
29+
default: "",
30+
parseHTML: (el) => el.getAttribute("data-chemform"),
31+
renderHTML: (attrs) => ({ "data-chemform": attrs.chemform }),
32+
},
33+
};
34+
},
35+
36+
parseHTML() {
37+
return [{ tag: 'span[data-type="crossreference"]' }];
38+
},
39+
40+
renderHTML({ HTMLAttributes }) {
41+
return ["span", mergeAttributes(HTMLAttributes, { "data-type": "crossreference" })];
42+
},
43+
44+
addNodeView() {
45+
return VueNodeViewRenderer(CrossReferenceComponent);
46+
},
47+
});

0 commit comments

Comments
 (0)