@modusoperandi/licit-floatingmenu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FloatingMenuPlugin.d.ts +44 -0
- package/FloatingMenuPlugin.js +454 -0
- package/FloatingPopup.d.ts +23 -0
- package/FloatingPopup.js +40 -0
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/model.d.ts +17 -0
- package/model.js +1 -0
- package/package.json +79 -0
- package/slice.d.ts +14 -0
- package/slice.js +61 -0
- package/styles.css +1 -0
- package/ui/menu.css +52 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DecorationSet, EditorView } from 'prosemirror-view';
|
|
2
|
+
import { Node, Schema } from 'prosemirror-model';
|
|
3
|
+
import { Plugin, PluginKey, EditorState } from 'prosemirror-state';
|
|
4
|
+
import { PopUpHandle } from '@modusoperandi/licit-ui-commands';
|
|
5
|
+
import { createSliceManager } from './slice';
|
|
6
|
+
import { FloatRuntime } from './model';
|
|
7
|
+
export declare const CMPluginKey: PluginKey<FloatingMenuPlugin>;
|
|
8
|
+
interface SliceModel {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
id: string;
|
|
12
|
+
referenceType: string;
|
|
13
|
+
source: string;
|
|
14
|
+
from: string;
|
|
15
|
+
to: string;
|
|
16
|
+
ids: string[];
|
|
17
|
+
}
|
|
18
|
+
export declare class FloatingMenuPlugin extends Plugin {
|
|
19
|
+
_popUpHandle: PopUpHandle | null;
|
|
20
|
+
_view: EditorView | null;
|
|
21
|
+
sliceManager: ReturnType<typeof createSliceManager>;
|
|
22
|
+
constructor(sliceRuntime: FloatRuntime);
|
|
23
|
+
getEffectiveSchema(schema: Schema): Schema;
|
|
24
|
+
}
|
|
25
|
+
export declare function copySelectionRich(view: EditorView, plugin: FloatingMenuPlugin): void;
|
|
26
|
+
export declare function createSliceObject(editorView: EditorView): SliceModel;
|
|
27
|
+
export declare function copySelectionPlain(view: EditorView, plugin: FloatingMenuPlugin): void;
|
|
28
|
+
export declare function pasteFromClipboard(view: EditorView, plugin: FloatingMenuPlugin): Promise<void>;
|
|
29
|
+
export declare function pasteAsReference(view: EditorView, plugin: FloatingMenuPlugin): Promise<void>;
|
|
30
|
+
export declare function pasteAsPlainText(view: EditorView, plugin: FloatingMenuPlugin): Promise<void>;
|
|
31
|
+
export declare function clipboardHasProseMirrorData(): Promise<boolean>;
|
|
32
|
+
export declare function getDecorations(doc: Node, state: EditorState): DecorationSet;
|
|
33
|
+
export declare function openFloatingMenu(plugin: FloatingMenuPlugin, view: EditorView, pos: number, anchorEl?: HTMLElement, contextPos?: {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
}): void;
|
|
37
|
+
export declare function addAltRightClickHandler(view: EditorView, plugin: FloatingMenuPlugin): void;
|
|
38
|
+
export declare function getDocSlices(this: FloatingMenuPlugin, view: EditorView): Promise<void>;
|
|
39
|
+
export declare function changeAttribute(_view: EditorView): void;
|
|
40
|
+
export declare function createNewSlice(view: EditorView): void;
|
|
41
|
+
export declare function showReferences(view: EditorView): Promise<void>;
|
|
42
|
+
export declare function createInfoIconHandler(view: EditorView): void;
|
|
43
|
+
export declare function createCitationHandler(view: EditorView): void;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// A generic Floating Menu ProseMirror Plugin
|
|
2
|
+
import { Decoration, DecorationSet } from 'prosemirror-view';
|
|
3
|
+
import { Slice } from 'prosemirror-model';
|
|
4
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
5
|
+
import { createPopUp, atAnchorBottomLeft, } from '@modusoperandi/licit-ui-commands';
|
|
6
|
+
import { FloatingMenu } from './FloatingPopup';
|
|
7
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
8
|
+
import { insertReference } from '@modusoperandi/licit-referencing';
|
|
9
|
+
import { createSliceManager } from './slice';
|
|
10
|
+
export const CMPluginKey = new PluginKey('floating-menu');
|
|
11
|
+
export class FloatingMenuPlugin extends Plugin {
|
|
12
|
+
_popUpHandle = null;
|
|
13
|
+
_view = null;
|
|
14
|
+
sliceManager;
|
|
15
|
+
constructor(sliceRuntime) {
|
|
16
|
+
const sliceManager = createSliceManager(sliceRuntime);
|
|
17
|
+
super({
|
|
18
|
+
key: CMPluginKey,
|
|
19
|
+
state: {
|
|
20
|
+
init(_config, state) {
|
|
21
|
+
return {
|
|
22
|
+
decorations: getDecorations(state.doc, state),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
apply(tr, prev, _oldState, newState) {
|
|
26
|
+
let decos = prev.decorations;
|
|
27
|
+
if (!tr.docChanged) {
|
|
28
|
+
return { decorations: decos?.map(tr.mapping, tr.doc) };
|
|
29
|
+
}
|
|
30
|
+
decos = decos.map(tr.mapping, tr.doc);
|
|
31
|
+
const requiresRescan = tr.steps.some((step) => {
|
|
32
|
+
const s = step.toJSON();
|
|
33
|
+
return (s.stepType === 'replace' ||
|
|
34
|
+
s.stepType === 'replaceAround' ||
|
|
35
|
+
s.stepType === 'setNodeMarkup');
|
|
36
|
+
}) || tr.getMeta(CMPluginKey)?.forceRescan;
|
|
37
|
+
if (requiresRescan) {
|
|
38
|
+
decos = getDecorations(tr.doc, newState);
|
|
39
|
+
}
|
|
40
|
+
return { decorations: decos };
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
props: {
|
|
44
|
+
decorations(state) {
|
|
45
|
+
return this.getState(state)?.decorations;
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
view: (view) => {
|
|
49
|
+
const plugin = this;
|
|
50
|
+
plugin._view = view;
|
|
51
|
+
plugin.sliceManager = sliceManager;
|
|
52
|
+
getDocSlices.call(plugin, view);
|
|
53
|
+
view.dom.addEventListener('pointerdown', (e) => {
|
|
54
|
+
const targetEl = e.target.closest('.float-icon');
|
|
55
|
+
if (!targetEl)
|
|
56
|
+
return;
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
e.stopPropagation();
|
|
59
|
+
const wrapper = targetEl.closest('.pm-hamburger-wrapper');
|
|
60
|
+
wrapper?.classList.add('popup-open');
|
|
61
|
+
const pos = Number(targetEl.dataset.pos);
|
|
62
|
+
openFloatingMenu(plugin, view, pos, targetEl);
|
|
63
|
+
});
|
|
64
|
+
// --- Alt + Right Click handler ---
|
|
65
|
+
view.dom.addEventListener('contextmenu', (e) => {
|
|
66
|
+
if (e.altKey && e.button === 2 && view.editable) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
const pos = {
|
|
70
|
+
x: e ? e.clientX : 0,
|
|
71
|
+
y: e ? e.clientY : 0,
|
|
72
|
+
};
|
|
73
|
+
openFloatingMenu(plugin, view, undefined, undefined, pos);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
// --- Close popup on outside click ---
|
|
77
|
+
const outsideClickHandler = (e) => {
|
|
78
|
+
const el = e.target;
|
|
79
|
+
if (plugin._popUpHandle &&
|
|
80
|
+
!el.closest('.context-menu') &&
|
|
81
|
+
!el.closest('.float-icon')) {
|
|
82
|
+
plugin._popUpHandle.close(null);
|
|
83
|
+
plugin._popUpHandle = null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
document.addEventListener('click', outsideClickHandler);
|
|
87
|
+
return {};
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
getEffectiveSchema(schema) {
|
|
92
|
+
return schema;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function copySelectionRich(view, plugin) {
|
|
96
|
+
const { state } = view;
|
|
97
|
+
if (state.selection.empty)
|
|
98
|
+
return;
|
|
99
|
+
if (!view.hasFocus())
|
|
100
|
+
view.focus();
|
|
101
|
+
const slice = state.selection.content();
|
|
102
|
+
const sliceJSON = {
|
|
103
|
+
content: slice.content.toJSON(),
|
|
104
|
+
openStart: slice.openStart,
|
|
105
|
+
openEnd: slice.openEnd,
|
|
106
|
+
sliceModel: createSliceObject(view),
|
|
107
|
+
};
|
|
108
|
+
navigator.clipboard
|
|
109
|
+
.writeText(JSON.stringify(sliceJSON))
|
|
110
|
+
.then(() => console.log('Rich content copied'))
|
|
111
|
+
.catch((err) => console.error('Clipboard write failed', err));
|
|
112
|
+
if (plugin._popUpHandle) {
|
|
113
|
+
plugin._popUpHandle.update({
|
|
114
|
+
...plugin._popUpHandle['props'],
|
|
115
|
+
pasteAsReferenceEnabled: true,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (plugin._popUpHandle?.close) {
|
|
119
|
+
plugin._popUpHandle.close(null);
|
|
120
|
+
plugin._popUpHandle = null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function createSliceObject(editorView) {
|
|
124
|
+
const instanceUrl = 'http://modusoperandi.com/editor/instance/';
|
|
125
|
+
const referenceUrl = 'http://modusoperandi.com/ont/document#Reference_nodes';
|
|
126
|
+
const sliceModel = {
|
|
127
|
+
name: '',
|
|
128
|
+
description: '',
|
|
129
|
+
id: '',
|
|
130
|
+
referenceType: '',
|
|
131
|
+
source: '',
|
|
132
|
+
from: '',
|
|
133
|
+
to: '',
|
|
134
|
+
ids: [],
|
|
135
|
+
};
|
|
136
|
+
const objectIds = [];
|
|
137
|
+
let firstParagraphText = null;
|
|
138
|
+
editorView.focus();
|
|
139
|
+
const $from = editorView.state.selection.$from;
|
|
140
|
+
const $to = editorView.state.selection.$to;
|
|
141
|
+
const from = $from.start($from.depth);
|
|
142
|
+
const to = $to.end($to.depth);
|
|
143
|
+
editorView.state.doc.nodesBetween(from, to, (node) => {
|
|
144
|
+
if (node.type.name === 'paragraph') {
|
|
145
|
+
if (!firstParagraphText && node.textContent?.trim()) {
|
|
146
|
+
firstParagraphText = node.textContent.trim();
|
|
147
|
+
}
|
|
148
|
+
if (node.attrs?.objectId) {
|
|
149
|
+
objectIds.push(node.attrs.objectId);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
sliceModel.id = instanceUrl + uuidv4();
|
|
154
|
+
sliceModel.ids = objectIds;
|
|
155
|
+
sliceModel.from = objectIds.length > 0 ? objectIds[0] : '';
|
|
156
|
+
sliceModel.to = objectIds.length > 0 ? objectIds[objectIds.length - 1] : '';
|
|
157
|
+
const viewWithDocView = editorView;
|
|
158
|
+
sliceModel.source = viewWithDocView?.['docView']?.node?.attrs?.objectId;
|
|
159
|
+
sliceModel.referenceType = referenceUrl;
|
|
160
|
+
const today = new Date().toISOString().split('T')[0];
|
|
161
|
+
const snippet = (firstParagraphText || 'Untitled').substring(0, 20);
|
|
162
|
+
sliceModel.name = `${snippet} - ${today}`;
|
|
163
|
+
return sliceModel;
|
|
164
|
+
}
|
|
165
|
+
export function copySelectionPlain(view, plugin) {
|
|
166
|
+
if (!view.hasFocus()) {
|
|
167
|
+
view.focus();
|
|
168
|
+
}
|
|
169
|
+
const { from, to } = view.state.selection;
|
|
170
|
+
if (from === to)
|
|
171
|
+
return;
|
|
172
|
+
const slice = view.state.doc.slice(from, to);
|
|
173
|
+
const text = slice.content.textBetween(0, slice.content.size, '\n');
|
|
174
|
+
navigator.clipboard
|
|
175
|
+
.writeText(text)
|
|
176
|
+
.then(() => console.log('Plain text copied!'))
|
|
177
|
+
.catch((err) => console.error('Clipboard write failed:', err));
|
|
178
|
+
if (plugin._popUpHandle?.close) {
|
|
179
|
+
plugin._popUpHandle.close(null);
|
|
180
|
+
plugin._popUpHandle = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export async function pasteFromClipboard(view, plugin) {
|
|
184
|
+
try {
|
|
185
|
+
if (!view.hasFocus())
|
|
186
|
+
view.focus();
|
|
187
|
+
const text = await navigator.clipboard.readText();
|
|
188
|
+
let tr;
|
|
189
|
+
try {
|
|
190
|
+
// Try parsing as JSON slice
|
|
191
|
+
const parsed = JSON.parse(text);
|
|
192
|
+
const slice = Slice.fromJSON(view.state.schema, parsed);
|
|
193
|
+
tr = view.state.tr.replaceSelection(slice);
|
|
194
|
+
}
|
|
195
|
+
catch (jsonErr) {
|
|
196
|
+
// If not JSON, treat as plain text
|
|
197
|
+
tr = view.state.tr.insertText(text, view.state.selection.from, view.state.selection.to);
|
|
198
|
+
}
|
|
199
|
+
view.dispatch(tr.scrollIntoView());
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
console.error('Clipboard paste failed:', err);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
if (plugin._popUpHandle?.close) {
|
|
206
|
+
plugin._popUpHandle.close(null);
|
|
207
|
+
plugin._popUpHandle = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export async function pasteAsReference(view, plugin) {
|
|
212
|
+
try {
|
|
213
|
+
if (!view.hasFocus())
|
|
214
|
+
view.focus();
|
|
215
|
+
const text = await navigator.clipboard.readText();
|
|
216
|
+
const parsed = JSON.parse(text);
|
|
217
|
+
const sliceModel = parsed.sliceModel;
|
|
218
|
+
if (!plugin.sliceManager?.createSliceViaDialog) {
|
|
219
|
+
throw new Error('SliceManager or createSliceViaDialog is not initialized');
|
|
220
|
+
}
|
|
221
|
+
const val = await plugin.sliceManager.createSliceViaDialog(sliceModel);
|
|
222
|
+
if (!val) {
|
|
223
|
+
console.warn('Slice creation returned no value, skipping insertReference.');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
insertReference(view, val.id, val.source, view['docView']?.node?.attrs?.objectMetaData?.name);
|
|
227
|
+
console.log('Slice created successfully:', val);
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
console.error('Failed to paste content or create slice:', err);
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
if (plugin._popUpHandle?.close) {
|
|
234
|
+
plugin._popUpHandle.close(null);
|
|
235
|
+
plugin._popUpHandle = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export async function pasteAsPlainText(view, plugin) {
|
|
240
|
+
try {
|
|
241
|
+
if (!view.hasFocus())
|
|
242
|
+
view.focus();
|
|
243
|
+
const text = await navigator.clipboard.readText();
|
|
244
|
+
let plainText = text;
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(text);
|
|
247
|
+
const slice = Slice.fromJSON(view.state.schema, parsed);
|
|
248
|
+
const frag = slice.content;
|
|
249
|
+
plainText = '';
|
|
250
|
+
frag.forEach((node) => {
|
|
251
|
+
plainText += node.textContent + '\n';
|
|
252
|
+
});
|
|
253
|
+
plainText = plainText.trim();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Not JSON → just keep as is
|
|
257
|
+
}
|
|
258
|
+
const { state } = view;
|
|
259
|
+
const tr = state.tr.insertText(plainText, state.selection.from, state.selection.to);
|
|
260
|
+
view.dispatch(tr.scrollIntoView());
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
console.error('Plain text paste failed:', err);
|
|
264
|
+
}
|
|
265
|
+
if (plugin._popUpHandle?.close) {
|
|
266
|
+
plugin._popUpHandle.close(null);
|
|
267
|
+
plugin._popUpHandle = null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export async function clipboardHasProseMirrorData() {
|
|
271
|
+
try {
|
|
272
|
+
const text = await navigator.clipboard.readText();
|
|
273
|
+
if (!text)
|
|
274
|
+
return false;
|
|
275
|
+
const parsed = JSON.parse(text);
|
|
276
|
+
return ((parsed &&
|
|
277
|
+
typeof parsed === 'object' &&
|
|
278
|
+
parsed.content &&
|
|
279
|
+
Array.isArray(parsed.content)) ||
|
|
280
|
+
parsed.content.type);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// --- Decoration function ---
|
|
287
|
+
export function getDecorations(doc, state) {
|
|
288
|
+
const decorations = [];
|
|
289
|
+
doc?.forEach((node, pos) => {
|
|
290
|
+
if (node.type.name !== 'paragraph')
|
|
291
|
+
return;
|
|
292
|
+
const wrapper = document.createElement('span');
|
|
293
|
+
wrapper.className = 'pm-hamburger-wrapper';
|
|
294
|
+
const hamburger = document.createElement('span');
|
|
295
|
+
// ✅ Use FontAwesome
|
|
296
|
+
hamburger.className = 'float-icon fa fa-bars';
|
|
297
|
+
hamburger.style.fontFamily = 'FontAwesome'; // for fa compatibility
|
|
298
|
+
hamburger.dataset.pos = String(pos);
|
|
299
|
+
wrapper.appendChild(hamburger);
|
|
300
|
+
decorations.push(Decoration.widget(pos + 1, wrapper, { side: 1 }));
|
|
301
|
+
const decoFlags = node.attrs?.isDeco;
|
|
302
|
+
if (!decoFlags)
|
|
303
|
+
return;
|
|
304
|
+
if (decoFlags.isSlice || decoFlags.isTag || decoFlags.isComment) {
|
|
305
|
+
// --- Container for gutter marks ---
|
|
306
|
+
const container = document.createElement('span');
|
|
307
|
+
container.style.position = 'absolute';
|
|
308
|
+
container.style.left = '27px';
|
|
309
|
+
container.style.display = 'inline-flex';
|
|
310
|
+
container.style.gap = '6px';
|
|
311
|
+
container.style.alignItems = 'center';
|
|
312
|
+
container.contentEditable = 'false';
|
|
313
|
+
container.style.userSelect = 'none';
|
|
314
|
+
// --- Slice ---
|
|
315
|
+
if (decoFlags.isSlice) {
|
|
316
|
+
const SliceMark = document.createElement('span');
|
|
317
|
+
SliceMark.id = `slicemark-${uuidv4()}`;
|
|
318
|
+
SliceMark.style.fontFamily = 'FontAwesome';
|
|
319
|
+
SliceMark.innerHTML = '';
|
|
320
|
+
SliceMark.onclick = () => console.log('Slice deco clicked');
|
|
321
|
+
container.appendChild(SliceMark);
|
|
322
|
+
}
|
|
323
|
+
// --- Tag ---
|
|
324
|
+
if (decoFlags.isTag) {
|
|
325
|
+
const TagMark = document.createElement('span');
|
|
326
|
+
TagMark.style.fontFamily = 'FontAwesome';
|
|
327
|
+
TagMark.innerHTML = '';
|
|
328
|
+
TagMark.onclick = () => console.log('Tag deco clicked');
|
|
329
|
+
container.appendChild(TagMark);
|
|
330
|
+
}
|
|
331
|
+
// --- Comment ---
|
|
332
|
+
if (decoFlags.isComment) {
|
|
333
|
+
const CommentMark = document.createElement('span');
|
|
334
|
+
CommentMark.style.fontFamily = 'FontAwesome';
|
|
335
|
+
CommentMark.innerHTML = '';
|
|
336
|
+
CommentMark.onclick = () => console.log('Comment deco clicked');
|
|
337
|
+
container.appendChild(CommentMark);
|
|
338
|
+
}
|
|
339
|
+
decorations.push(Decoration.widget(pos + 1, container, { side: -1 }));
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
return DecorationSet.create(state.doc, decorations);
|
|
343
|
+
}
|
|
344
|
+
export function openFloatingMenu(plugin, view, pos, anchorEl, contextPos) {
|
|
345
|
+
// Close existing popup if any
|
|
346
|
+
if (plugin._popUpHandle) {
|
|
347
|
+
plugin._popUpHandle.close(null);
|
|
348
|
+
plugin._popUpHandle = null;
|
|
349
|
+
}
|
|
350
|
+
// Determine if clipboard has ProseMirror data
|
|
351
|
+
clipboardHasProseMirrorData().then((hasPM) => {
|
|
352
|
+
plugin._popUpHandle = createPopUp(FloatingMenu, {
|
|
353
|
+
editorState: view.state,
|
|
354
|
+
editorView: view,
|
|
355
|
+
paragraphPos: pos,
|
|
356
|
+
pasteAsReferenceEnabled: hasPM,
|
|
357
|
+
copyRichHandler: () => copySelectionRich(view, plugin),
|
|
358
|
+
copyPlainHandler: () => copySelectionPlain(view, plugin),
|
|
359
|
+
pasteHandler: () => pasteFromClipboard(view, plugin),
|
|
360
|
+
pasteAsReferenceHandler: () => pasteAsReference(view, plugin),
|
|
361
|
+
pastePlainHandler: () => pasteAsPlainText(view, plugin),
|
|
362
|
+
createInfoIconHandler: () => createInfoIconHandler(view),
|
|
363
|
+
createCitationHandler: () => createCitationHandler(view),
|
|
364
|
+
}, {
|
|
365
|
+
anchor: anchorEl || view.dom,
|
|
366
|
+
contextPos: contextPos,
|
|
367
|
+
position: atAnchorBottomLeft,
|
|
368
|
+
autoDismiss: false,
|
|
369
|
+
onClose: () => {
|
|
370
|
+
plugin._popUpHandle = null;
|
|
371
|
+
// Remove 'popup-open' class if anchor is a hamburger wrapper
|
|
372
|
+
anchorEl
|
|
373
|
+
?.closest('.pm-hamburger-wrapper')
|
|
374
|
+
?.classList.remove('popup-open');
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
export function addAltRightClickHandler(view, plugin) {
|
|
380
|
+
view.dom.addEventListener('contextmenu', (e) => {
|
|
381
|
+
if (e.altKey && e.button === 2) {
|
|
382
|
+
e.preventDefault();
|
|
383
|
+
e.stopPropagation();
|
|
384
|
+
const pos = view.posAtCoords({ left: e.clientX, top: e.clientY })?.pos;
|
|
385
|
+
if (pos == null)
|
|
386
|
+
return;
|
|
387
|
+
openFloatingMenu(plugin, view, pos);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
// To retrieve all the document slices from the server and cache it.
|
|
392
|
+
export async function getDocSlices(view) {
|
|
393
|
+
try {
|
|
394
|
+
const result = await this.sliceManager?.getDocumentSlices(view);
|
|
395
|
+
this.sliceManager?.setSlices(result, view.state);
|
|
396
|
+
this.sliceManager?.setSliceAttrs(view);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
console.error('Failed to load slices:', err);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
export function changeAttribute(_view) {
|
|
403
|
+
const from = _view.state.selection.$from.before(1);
|
|
404
|
+
const node = _view.state.doc.nodeAt(from);
|
|
405
|
+
if (!node)
|
|
406
|
+
return; // early return if node does not exist
|
|
407
|
+
let tr = _view.state.tr;
|
|
408
|
+
const newattrs = { ...node.attrs };
|
|
409
|
+
const isDeco = { ...(newattrs.isDeco || {}) };
|
|
410
|
+
isDeco.isSlice = true;
|
|
411
|
+
newattrs.isDeco = isDeco;
|
|
412
|
+
tr = tr.setNodeMarkup(from, undefined, newattrs);
|
|
413
|
+
_view.dispatch(tr);
|
|
414
|
+
}
|
|
415
|
+
export function createNewSlice(view) {
|
|
416
|
+
const sliceModel = createSliceObject(view);
|
|
417
|
+
const plugin = CMPluginKey.get(view.state);
|
|
418
|
+
if (!plugin)
|
|
419
|
+
return;
|
|
420
|
+
plugin.sliceManager
|
|
421
|
+
.createSliceViaDialog(sliceModel)
|
|
422
|
+
.then((val) => {
|
|
423
|
+
plugin.sliceManager.addSliceToList(val);
|
|
424
|
+
changeAttribute(view);
|
|
425
|
+
})
|
|
426
|
+
.catch((err) => {
|
|
427
|
+
console.error('createSlice failed with:', err);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
export async function showReferences(view) {
|
|
431
|
+
const plugin = CMPluginKey.get(view.state);
|
|
432
|
+
if (!plugin)
|
|
433
|
+
return;
|
|
434
|
+
plugin.sliceManager
|
|
435
|
+
.insertReference()
|
|
436
|
+
.then((val) => {
|
|
437
|
+
insertReference(view, val.id, val.source, view['docView']?.node?.attrs?.objectMetaData?.name);
|
|
438
|
+
})
|
|
439
|
+
.catch((err) => {
|
|
440
|
+
console.error('createSlice failed with:', err);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
export function createInfoIconHandler(view) {
|
|
444
|
+
const plugin = CMPluginKey.get(view.state);
|
|
445
|
+
if (!plugin)
|
|
446
|
+
return;
|
|
447
|
+
plugin.sliceManager?.addInfoIcon();
|
|
448
|
+
}
|
|
449
|
+
export function createCitationHandler(view) {
|
|
450
|
+
const plugin = CMPluginKey.get(view.state);
|
|
451
|
+
if (!plugin)
|
|
452
|
+
return;
|
|
453
|
+
plugin.sliceManager?.addCitation();
|
|
454
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { EditorView } from 'prosemirror-view';
|
|
3
|
+
import { EditorState } from 'prosemirror-state';
|
|
4
|
+
interface FloatingMenuProps {
|
|
5
|
+
editorState: EditorState;
|
|
6
|
+
editorView: EditorView;
|
|
7
|
+
paragraphPos: number;
|
|
8
|
+
pasteAsReferenceEnabled: boolean;
|
|
9
|
+
copyRichHandler: () => void;
|
|
10
|
+
copyPlainHandler: () => void;
|
|
11
|
+
createCitationHandler: () => void;
|
|
12
|
+
createInfoIconHandler: () => void;
|
|
13
|
+
pasteHandler: () => void;
|
|
14
|
+
pasteAsReferenceHandler: () => void;
|
|
15
|
+
pastePlainHandler: () => void;
|
|
16
|
+
close?: (menuName: string) => void;
|
|
17
|
+
}
|
|
18
|
+
export declare class FloatingMenu extends React.PureComponent<FloatingMenuProps, FloatingMenuProps> {
|
|
19
|
+
constructor(props: any);
|
|
20
|
+
render(): React.ReactNode;
|
|
21
|
+
closePopup(menuName: string): void;
|
|
22
|
+
}
|
|
23
|
+
export {};
|
package/FloatingPopup.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { CustomButton } from '@modusoperandi/licit-ui-commands';
|
|
3
|
+
import { createNewSlice, showReferences } from './FloatingMenuPlugin';
|
|
4
|
+
export class FloatingMenu extends React.PureComponent {
|
|
5
|
+
constructor(props) {
|
|
6
|
+
super(props);
|
|
7
|
+
this.state = {
|
|
8
|
+
...props,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
render() {
|
|
12
|
+
const { editorState, paragraphPos } = this.props;
|
|
13
|
+
const { selection } = editorState;
|
|
14
|
+
const $from = selection.$from;
|
|
15
|
+
const $to = selection.$to;
|
|
16
|
+
const inThisParagraph = $from.before($from.depth) === paragraphPos &&
|
|
17
|
+
$to.before($to.depth) === paragraphPos;
|
|
18
|
+
const isTextSelected = inThisParagraph && !selection.empty;
|
|
19
|
+
const enableCitationAndComment = isTextSelected;
|
|
20
|
+
const enableTagAndInfoicon = inThisParagraph;
|
|
21
|
+
return (React.createElement("div", { className: "context-menu", role: "menu", tabIndex: -1 },
|
|
22
|
+
React.createElement("div", { className: "context-menu__items" },
|
|
23
|
+
React.createElement(CustomButton, { disabled: !enableCitationAndComment, label: "Create Citation", onClick: this.props.createCitationHandler }),
|
|
24
|
+
React.createElement(CustomButton, { disabled: !enableTagAndInfoicon, label: "Create Infoicon", onClick: this.props.createInfoIconHandler }),
|
|
25
|
+
React.createElement(CustomButton, { label: "Copy", onClick: this.props.copyRichHandler }),
|
|
26
|
+
React.createElement(CustomButton, { label: "Copy Without Formatting", onClick: this.props.copyPlainHandler }),
|
|
27
|
+
React.createElement(CustomButton, { label: "Paste", onClick: () => {
|
|
28
|
+
this.props.pasteHandler();
|
|
29
|
+
} }),
|
|
30
|
+
React.createElement(CustomButton, { label: "Paste As Plain Text", onClick: this.props.pastePlainHandler }),
|
|
31
|
+
React.createElement(CustomButton, { disabled: !this.props.pasteAsReferenceEnabled, label: "Paste As Reference", onClick: () => {
|
|
32
|
+
this.props.pasteAsReferenceHandler();
|
|
33
|
+
} }),
|
|
34
|
+
React.createElement(CustomButton, { label: "Create Bookmark", onClick: () => { createNewSlice(this.props.editorView); this.props.close?.('Create Slice'); } }),
|
|
35
|
+
React.createElement(CustomButton, { label: "Insert Reference", onClick: () => { showReferences(this.props.editorView); this.props.close?.('Insert reference'); } }))));
|
|
36
|
+
}
|
|
37
|
+
closePopup(menuName) {
|
|
38
|
+
this.props.close?.(menuName);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Modus Operandi Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# licit-plugin-contrib-floatingmenu
|
|
2
|
+
Licit plugin for managing an editor context menu
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
### Dependency
|
|
6
|
+
|
|
7
|
+
### Commands
|
|
8
|
+
|
|
9
|
+
- npm install
|
|
10
|
+
|
|
11
|
+
- npm run ci:build
|
|
12
|
+
|
|
13
|
+
- npm pack
|
|
14
|
+
|
|
15
|
+
#### To use this in Licit
|
|
16
|
+
|
|
17
|
+
Include plugin in licit component
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
import { FloatingMenuPlugin } from '@modusoperandi/licit-floatingmenu';
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
const plugins = [new FloatingMenuPlugin(this.runtime)]
|
|
25
|
+
|
|
26
|
+
ReactDOM.render(<Licit docID={0} plugins={plugins}/>)
|
|
27
|
+
|
|
28
|
+
```
|
package/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FloatingMenuPlugin';
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FloatingMenuPlugin';
|
package/model.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SliceModel {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
id: string;
|
|
5
|
+
referenceType: string;
|
|
6
|
+
source: string;
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
ids: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface FloatRuntime {
|
|
12
|
+
createSlice(slice: SliceModel): Promise<SliceModel>;
|
|
13
|
+
retrieveSlices(): Promise<SliceModel[]>;
|
|
14
|
+
insertInfoIconFloat(): void;
|
|
15
|
+
insertCitationFloat(): void;
|
|
16
|
+
insertReference(): Promise<SliceModel>;
|
|
17
|
+
}
|
package/model.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@modusoperandi/licit-floatingmenu",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "licit-floatingmenu plugin built with ProseMirror",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
8
|
+
"style": "styles.css",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/MO-Movia/licit-plugin-contrib-floatingmenu"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"lint": "eslint src",
|
|
16
|
+
"test": "jest --coverage",
|
|
17
|
+
"test:unit": "jest",
|
|
18
|
+
"test:coverage": "jest --coverage",
|
|
19
|
+
"ci:build": "tsc --build tsconfig.prod.json && copyfiles --up 1 \"src/**/*.css\" dist && copyfiles package.json LICENSE README.md dist",
|
|
20
|
+
"ci:bom": "npx @cyclonedx/cyclonedx-npm --ignore-npm-errors --short-PURLs --output-format XML --output-file dist/bom.xml",
|
|
21
|
+
"debug": "node --debug-brk --inspect ./node_modules/.bin/jest -i",
|
|
22
|
+
"verify": "npm run lint -- --fix && npm run ci:build && npm run test:coverage && echo 'All Tests Passed!'"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@modusoperandi/licit-ui-commands": "^1.0.0",
|
|
26
|
+
"@modusoperandi/licit-referencing": "^1.0.0",
|
|
27
|
+
"prosemirror-model": "^1.19.4",
|
|
28
|
+
"prosemirror-state": "^1.4.2",
|
|
29
|
+
"prosemirror-transform": "^1.7.0",
|
|
30
|
+
"prosemirror-view": "^1.27.0",
|
|
31
|
+
"prosemirror-schema-basic": "^1.2.0",
|
|
32
|
+
"prosemirror-schema-list": "^1.2.0",
|
|
33
|
+
"prosemirror-utils": "^1.0.0"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"font-awesome": "^4.7.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@cfaester/enzyme-adapter-react-18": "^0.8.0",
|
|
40
|
+
"@testing-library/jest-dom": "^6.4.1",
|
|
41
|
+
"@testing-library/react": "^16.0.0",
|
|
42
|
+
"@testing-library/user-event": "^14.4.3",
|
|
43
|
+
"@types/jest": "^29.0.2",
|
|
44
|
+
"@types/node": "^20.11.17",
|
|
45
|
+
"@types/orderedmap": "^2.0.0",
|
|
46
|
+
"@types/react": "^18.0.21",
|
|
47
|
+
"@types/react-dom": "^18.0.6",
|
|
48
|
+
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
49
|
+
"@typescript-eslint/parser": "^7.0.2",
|
|
50
|
+
"copyfiles": "^2.4.1",
|
|
51
|
+
"enzyme": "^3.11.0",
|
|
52
|
+
"eslint": "^8.56.0",
|
|
53
|
+
"eslint-config-prettier": "^9.1.0",
|
|
54
|
+
"eslint-plugin-jest": "^28.2.0",
|
|
55
|
+
"eslint-plugin-prettier": "^5.1.3",
|
|
56
|
+
"eslint-plugin-react": "^7.24.0",
|
|
57
|
+
"husky": "^9.0.10",
|
|
58
|
+
"identity-obj-proxy": "^3.0.0",
|
|
59
|
+
"jest": "^29.3.1",
|
|
60
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
61
|
+
"jest-junit": "^16.0.0",
|
|
62
|
+
"jest-mock-extended": "^3.0.4",
|
|
63
|
+
"jest-prosemirror": "^2.1.5",
|
|
64
|
+
"jest-sonar-reporter": "^2.0.0",
|
|
65
|
+
"lint-staged": "^15.2.1",
|
|
66
|
+
"prettier": "^3.0.0",
|
|
67
|
+
"stylelint-config-standard": "^36.0.0",
|
|
68
|
+
"stylelint-prettier": "^5.0.0",
|
|
69
|
+
"ts-jest": "^29.0.7",
|
|
70
|
+
"ts-node": "^10.4.0",
|
|
71
|
+
"typescript": "^5.3.3"
|
|
72
|
+
},
|
|
73
|
+
"importSort": {
|
|
74
|
+
".js": {
|
|
75
|
+
"parser": "babylon",
|
|
76
|
+
"style": "module-grouping"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/slice.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { EditorView } from 'prosemirror-view';
|
|
2
|
+
import { EditorState } from 'prosemirror-state';
|
|
3
|
+
import { FloatRuntime, SliceModel } from './model';
|
|
4
|
+
export declare function createSliceManager(runtime: FloatRuntime): {
|
|
5
|
+
setSlices: (slices: SliceModel[], state: EditorState) => void;
|
|
6
|
+
getDocSlices: () => SliceModel[];
|
|
7
|
+
getDocumentSlices: (_view: EditorView) => Promise<SliceModel[]>;
|
|
8
|
+
addSliceToList: (slice: SliceModel) => SliceModel[];
|
|
9
|
+
setSliceAttrs: (view: EditorView) => void;
|
|
10
|
+
addInfoIcon: () => void;
|
|
11
|
+
addCitation: () => void;
|
|
12
|
+
createSliceViaDialog: (props: SliceModel) => Promise<SliceModel>;
|
|
13
|
+
insertReference: () => Promise<SliceModel>;
|
|
14
|
+
};
|
package/slice.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function createSliceManager(runtime) {
|
|
2
|
+
let docSlices = [];
|
|
3
|
+
// store slices in cache
|
|
4
|
+
function setSlices(slices, state) {
|
|
5
|
+
const objectId = state.doc.attrs.objectId;
|
|
6
|
+
const filteredSlices = slices.filter((slice) => slice.source === objectId);
|
|
7
|
+
docSlices = [...docSlices, ...filteredSlices];
|
|
8
|
+
}
|
|
9
|
+
function getDocSlices() {
|
|
10
|
+
return docSlices;
|
|
11
|
+
}
|
|
12
|
+
// retrieve document slices from server
|
|
13
|
+
function getDocumentSlices(_view) {
|
|
14
|
+
return runtime?.retrieveSlices();
|
|
15
|
+
}
|
|
16
|
+
// add new slice to cache
|
|
17
|
+
function addSliceToList(slice) {
|
|
18
|
+
docSlices.push(slice);
|
|
19
|
+
return docSlices;
|
|
20
|
+
}
|
|
21
|
+
// apply slice attributes to the doc
|
|
22
|
+
function setSliceAttrs(view) {
|
|
23
|
+
const result = getDocSlices();
|
|
24
|
+
let tr = view.state.tr;
|
|
25
|
+
result.forEach((obj) => {
|
|
26
|
+
view.state.doc.descendants((nodeactual, pos) => {
|
|
27
|
+
if (nodeactual?.attrs?.objectId === obj?.from) {
|
|
28
|
+
const newattrs = { ...nodeactual.attrs };
|
|
29
|
+
const isDeco = { ...(newattrs.isDeco || {}) };
|
|
30
|
+
isDeco.isSlice = true;
|
|
31
|
+
newattrs.isDeco = isDeco;
|
|
32
|
+
tr = tr.setNodeMarkup(pos, undefined, newattrs);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
view.dispatch(tr);
|
|
37
|
+
}
|
|
38
|
+
function addInfoIcon() {
|
|
39
|
+
return runtime?.insertInfoIconFloat();
|
|
40
|
+
}
|
|
41
|
+
function addCitation() {
|
|
42
|
+
return runtime?.insertCitationFloat();
|
|
43
|
+
}
|
|
44
|
+
function createSliceViaDialog(props) {
|
|
45
|
+
return runtime?.createSlice(props);
|
|
46
|
+
}
|
|
47
|
+
function insertReference() {
|
|
48
|
+
return runtime?.insertReference();
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
setSlices,
|
|
52
|
+
getDocSlices,
|
|
53
|
+
getDocumentSlices,
|
|
54
|
+
addSliceToList,
|
|
55
|
+
setSliceAttrs,
|
|
56
|
+
addInfoIcon,
|
|
57
|
+
addCitation,
|
|
58
|
+
createSliceViaDialog,
|
|
59
|
+
insertReference
|
|
60
|
+
};
|
|
61
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import url("./ui/menu.css");
|
package/ui/menu.css
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/* Wrapper for each paragraph hamburger */
|
|
2
|
+
.pm-hamburger-wrapper {
|
|
3
|
+
position: relative;
|
|
4
|
+
float: right;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* Icon hidden by default */
|
|
8
|
+
.float-icon {
|
|
9
|
+
position: absolute;
|
|
10
|
+
right: 0;
|
|
11
|
+
top: 0;
|
|
12
|
+
cursor: pointer;
|
|
13
|
+
margin-right: 6px;
|
|
14
|
+
color: #555;
|
|
15
|
+
font-size: 16px;
|
|
16
|
+
user-select: none;
|
|
17
|
+
opacity: 0; /* hidden initially */
|
|
18
|
+
transition: opacity 0.18s ease-in-out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Show icon only when hovering over the paragraph */
|
|
22
|
+
.ProseMirror p:hover .float-icon {
|
|
23
|
+
opacity: 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.context-menu {
|
|
27
|
+
position: absolute; /* positioned by createPopUp */
|
|
28
|
+
background: white;
|
|
29
|
+
border: 1px solid #ccc;
|
|
30
|
+
border-radius: 6px;
|
|
31
|
+
padding: 6px 8px;
|
|
32
|
+
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
33
|
+
z-index: 10000;
|
|
34
|
+
|
|
35
|
+
display: flex;
|
|
36
|
+
flex-direction: column;
|
|
37
|
+
|
|
38
|
+
/* 👇 important fix */
|
|
39
|
+
min-width: 180px;
|
|
40
|
+
min-height: 40px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
.context-menu__items {
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
gap: 4px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ProseMirror[contenteditable="false"] .pm-hamburger-wrapper {
|
|
51
|
+
display: none !important;
|
|
52
|
+
}
|