@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.
@@ -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 = '&#xf097';
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 = '&#xf02b;';
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 = '&#xf075;';
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 {};
@@ -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
+ }