@modusoperandi/licit-floatingmenu 0.1.2 → 0.1.4

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,6 @@
1
+ /**
2
+ * @license MIT
3
+ * @copyright Copyright 2025 Modus Operandi Inc. All Rights Reserved.
4
+ */
5
+ import { FloatingMenuItem } from './model';
6
+ export declare function getDefaultMenuItems(handlers: any): FloatingMenuItem[];
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @license MIT
3
+ * @copyright Copyright 2025 Modus Operandi Inc. All Rights Reserved.
4
+ */
5
+ export function getDefaultMenuItems(handlers) {
6
+ return [
7
+ {
8
+ id: 'comment',
9
+ label: 'Add Comment',
10
+ isEnabled: handlers.enableCitationAndComment,
11
+ onClick: handlers.addComment,
12
+ },
13
+ {
14
+ id: 'tag',
15
+ label: 'Add Tag',
16
+ isEnabled: handlers.enableCitationAndComment,
17
+ onClick: handlers.addTag,
18
+ },
19
+ {
20
+ id: 'citation',
21
+ label: 'Create Citation',
22
+ isEnabled: handlers.enableCitationAndComment,
23
+ onClick: handlers.createCitation,
24
+ },
25
+ {
26
+ id: 'info',
27
+ label: 'Create Infoicon',
28
+ isEnabled: handlers.enableTagAndInfoicon,
29
+ onClick: handlers.createInfoIcon,
30
+ },
31
+ {
32
+ id: 'copy',
33
+ label: 'Copy (Ctrl + C)',
34
+ isEnabled: handlers.enableCopy,
35
+ onClick: handlers.copyRich,
36
+ },
37
+ {
38
+ id: 'copy-plain',
39
+ label: 'Copy Without Formatting',
40
+ isEnabled: handlers.enableCopy,
41
+ onClick: handlers.copyPlain,
42
+ },
43
+ {
44
+ id: 'paste',
45
+ label: 'Paste (Ctrl + V)',
46
+ isEnabled: handlers.enablePaste,
47
+ onClick: handlers.paste,
48
+ },
49
+ {
50
+ id: 'paste-plain',
51
+ label: 'Paste As Plain Text',
52
+ isEnabled: handlers.enablePaste,
53
+ onClick: handlers.pastePlain,
54
+ },
55
+ {
56
+ id: 'paste-ref',
57
+ label: 'Paste As Reference (Ctrl + Alt + V)',
58
+ isEnabled: handlers.enablePasteAsReference,
59
+ onClick: handlers.pasteAsReference,
60
+ },
61
+ {
62
+ id: 'slice',
63
+ label: 'Create Referent',
64
+ onClick: handlers.createSlice,
65
+ },
66
+ {
67
+ id: 'insert-ref',
68
+ label: 'Insert Reference',
69
+ onClick: handlers.showReferences,
70
+ },
71
+ ];
72
+ }
@@ -8,7 +8,7 @@ import { Node, Schema } from 'prosemirror-model';
8
8
  import { Plugin, PluginKey, EditorState } from 'prosemirror-state';
9
9
  import { PopUpHandle, Rect } from '@modusoperandi/licit-ui-commands';
10
10
  import { createSliceManager } from './slice';
11
- import { FloatRuntime } from './model';
11
+ import { FloatRuntime, FloatingMenuItem } from './model';
12
12
  export declare const CMPluginKey: PluginKey<FloatingMenuPlugin>;
13
13
  interface SliceModel {
14
14
  name: string;
@@ -32,8 +32,9 @@ export declare class FloatingMenuPlugin extends Plugin {
32
32
  _popUpHandle: PopUpHandle | null;
33
33
  _view: EditorView | null;
34
34
  _urlConfig: UrlConfig | null;
35
+ menuItems?: FloatingMenuItem[];
35
36
  sliceManager: ReturnType<typeof createSliceManager>;
36
- constructor(sliceRuntime: FloatRuntime, urlConfig?: UrlConfig);
37
+ constructor(sliceRuntime: FloatRuntime, urlConfig?: UrlConfig, menuItems?: FloatingMenuItem[]);
37
38
  initKeyCommands(): Plugin[];
38
39
  getEffectiveSchema(schema: Schema): Schema;
39
40
  }
@@ -47,7 +48,27 @@ export declare function clipboardHasData(): Promise<boolean>;
47
48
  export declare function clipboardHasProseMirrorData(): Promise<boolean>;
48
49
  export declare function getDecorations(doc: Node, state: EditorState): DecorationSet;
49
50
  export declare function positionAboveOrBelow(anchorRect?: Rect, bodyRect?: Rect): Rect;
50
- export declare function openFloatingMenu(plugin: FloatingMenuPlugin, view: EditorView, pos: number, anchorEl?: HTMLElement, contextPos?: {
51
+ export declare function createMenuCallbacks(view: EditorView, plugin: FloatingMenuPlugin, hasClipboard?: boolean, hasPM?: boolean): {
52
+ enableCopy: () => boolean;
53
+ enablePaste: () => boolean;
54
+ enablePasteAsReference: () => boolean;
55
+ enableCitationAndComment: () => boolean;
56
+ enableTagAndInfoicon: () => boolean;
57
+ copyRich: () => void;
58
+ copyPlain: () => void;
59
+ paste: () => Promise<void>;
60
+ pastePlain: () => Promise<void>;
61
+ pasteAsReference: () => Promise<void>;
62
+ createCitation: () => void;
63
+ createInfoIcon: () => void;
64
+ createSlice: () => void;
65
+ showReferences: () => Promise<void>;
66
+ addComment: () => void;
67
+ addTag: () => void;
68
+ };
69
+ export declare function closeExistingPopup(plugin: FloatingMenuPlugin): void;
70
+ export declare function createOnCloseHandler(plugin: FloatingMenuPlugin, anchorEl?: HTMLElement): () => void;
71
+ export declare function openFloatingMenu(plugin: FloatingMenuPlugin, view: EditorView, pos?: number, anchorEl?: HTMLElement, contextPos?: {
51
72
  x: number;
52
73
  y: number;
53
74
  }): void;
@@ -12,17 +12,50 @@ import { v4 as uuidv4 } from 'uuid';
12
12
  import { insertReference } from '@modusoperandi/licit-referencing';
13
13
  import { createSliceManager } from './slice';
14
14
  import { createKeyMapPlugin, makeKeyMapWithCommon } from '@modusoperandi/licit-doc-attrs-step';
15
+ import { getDefaultMenuItems } from './FloatingMenuDefaults';
15
16
  export const CMPluginKey = new PluginKey('floating-menu');
16
17
  export const KEY_COPY = makeKeyMapWithCommon('FloatingMenuPlugin', 'Mod-c');
17
18
  export const KEY_CUT = makeKeyMapWithCommon('FloatingMenuPlugin', 'Mod-x');
18
19
  export const KEY_PASTE = makeKeyMapWithCommon('FloatingMenuPlugin', 'Mod-v');
19
20
  export const KEY_PASTE_REF = makeKeyMapWithCommon('FloatingMenuPlugin', 'Mod-Alt-v');
21
+ const EMPTY_DECORATIONS = DecorationSet.empty;
22
+ function stepAddsParagraph(content) {
23
+ if (!Array.isArray(content)) {
24
+ return false;
25
+ }
26
+ return content.some((item) => {
27
+ if (!item || typeof item !== 'object') {
28
+ return false;
29
+ }
30
+ const node = item;
31
+ return node.type === 'paragraph' || stepAddsParagraph(node.content);
32
+ });
33
+ }
34
+ function shouldRescanDecorations(tr) {
35
+ const forceRescan = typeof tr.getMeta === 'function'
36
+ ? tr.getMeta(CMPluginKey)?.forceRescan
37
+ : false;
38
+ if (forceRescan) {
39
+ return true;
40
+ }
41
+ return tr.steps.some((step) => {
42
+ const serializedStep = step.toJSON();
43
+ if (serializedStep.stepType === 'setNodeMarkup' || serializedStep.stepType === 'replaceAround') {
44
+ return true;
45
+ }
46
+ if (serializedStep.stepType !== 'replace') {
47
+ return false;
48
+ }
49
+ return stepAddsParagraph(serializedStep.slice?.content);
50
+ });
51
+ }
20
52
  export class FloatingMenuPlugin extends Plugin {
21
53
  _popUpHandle = null;
22
54
  _view = null;
23
55
  _urlConfig = null;
56
+ menuItems;
24
57
  sliceManager;
25
- constructor(sliceRuntime, urlConfig = {}) {
58
+ constructor(sliceRuntime, urlConfig = {}, menuItems) {
26
59
  const sliceManager = createSliceManager(sliceRuntime);
27
60
  super({
28
61
  key: CMPluginKey,
@@ -33,21 +66,19 @@ export class FloatingMenuPlugin extends Plugin {
33
66
  };
34
67
  },
35
68
  apply(tr, prev, _oldState, newState) {
36
- let decos = prev.decorations;
37
- if (!tr.docChanged) {
38
- return { decorations: decos ? DecorationSet.prototype.map.call(decos, tr.mapping, tr.doc) : decos };
69
+ const forceRescan = typeof tr.getMeta === 'function'
70
+ ? tr.getMeta(CMPluginKey)?.forceRescan
71
+ : false;
72
+ const mappedDecorations = prev.decorations
73
+ ? prev.decorations.map(tr.mapping, tr.doc)
74
+ : EMPTY_DECORATIONS;
75
+ if (!tr.docChanged && !forceRescan) {
76
+ return { decorations: mappedDecorations };
39
77
  }
40
- decos = DecorationSet.prototype.map.call(decos, tr.mapping, tr.doc);
41
- const requiresRescan = tr.steps.some((step) => {
42
- const s = step.toJSON();
43
- return (s.stepType === 'replace' ||
44
- s.stepType === 'replaceAround' ||
45
- s.stepType === 'setNodeMarkup');
46
- }) || tr.getMeta(CMPluginKey)?.forceRescan;
47
- if (requiresRescan) {
48
- decos = getDecorations(tr.doc, newState);
78
+ if (shouldRescanDecorations(tr)) {
79
+ return { decorations: getDecorations(tr.doc, newState) };
49
80
  }
50
- return { decorations: decos };
81
+ return { decorations: mappedDecorations };
51
82
  },
52
83
  },
53
84
  props: {
@@ -62,7 +93,7 @@ export class FloatingMenuPlugin extends Plugin {
62
93
  plugin.sliceManager = sliceManager;
63
94
  plugin._urlConfig = urlConfig;
64
95
  getDocSlices.call(plugin, view);
65
- view.dom.addEventListener('pointerdown', (e) => {
96
+ const pointerDownHandler = (e) => {
66
97
  const targetEl = getClosestHTMLElement(e.target, '.float-icon');
67
98
  if (!targetEl)
68
99
  return;
@@ -72,9 +103,10 @@ export class FloatingMenuPlugin extends Plugin {
72
103
  wrapper?.classList.add('popup-open');
73
104
  const pos = Number(targetEl.dataset.pos);
74
105
  openFloatingMenu(plugin, view, pos, targetEl);
75
- });
106
+ };
107
+ view.dom.addEventListener('pointerdown', pointerDownHandler);
76
108
  // --- Alt + Right Click handler ---
77
- view.dom.addEventListener('contextmenu', (e) => {
109
+ const contextMenuHandler = (e) => {
78
110
  if (e.altKey && e.button === 2 && view.editable) {
79
111
  e.preventDefault();
80
112
  e.stopPropagation();
@@ -84,7 +116,8 @@ export class FloatingMenuPlugin extends Plugin {
84
116
  };
85
117
  openFloatingMenu(plugin, view, undefined, undefined, pos);
86
118
  }
87
- });
119
+ };
120
+ view.dom.addEventListener('contextmenu', contextMenuHandler);
88
121
  // --- Close popup on outside click ---
89
122
  const outsideClickHandler = (e) => {
90
123
  const el = e.target;
@@ -96,9 +129,18 @@ export class FloatingMenuPlugin extends Plugin {
96
129
  }
97
130
  };
98
131
  document.addEventListener('click', outsideClickHandler);
99
- return {};
132
+ return {
133
+ destroy() {
134
+ view.dom.removeEventListener('pointerdown', pointerDownHandler);
135
+ view.dom.removeEventListener('contextmenu', contextMenuHandler);
136
+ document.removeEventListener('click', outsideClickHandler);
137
+ closeExistingPopup(plugin);
138
+ plugin._view = null;
139
+ },
140
+ };
100
141
  },
101
142
  });
143
+ this.menuItems = menuItems;
102
144
  }
103
145
  initKeyCommands() {
104
146
  return createKeyMapPlugin([
@@ -338,53 +380,58 @@ export function getDecorations(doc, state) {
338
380
  (node, pos) => {
339
381
  if (node.type.name !== 'paragraph')
340
382
  return;
341
- const wrapper = document.createElement('span');
342
- wrapper.className = 'pm-hamburger-wrapper';
343
- const hamburger = document.createElement('span');
344
- hamburger.className = 'float-icon fa fa-bars';
345
- hamburger.style.fontFamily = 'FontAwesome'; // for fa compatibility
346
- hamburger.dataset.pos = String(pos);
347
- wrapper.appendChild(hamburger);
348
- decorations.push(Decoration.widget(pos + 1, wrapper, { side: 1 }));
383
+ decorations.push(Decoration.widget(pos + 1, () => {
384
+ const wrapper = document.createElement('span');
385
+ wrapper.className = 'pm-hamburger-wrapper';
386
+ const hamburger = document.createElement('span');
387
+ hamburger.className = 'float-icon fa fa-bars';
388
+ hamburger.style.fontFamily = 'FontAwesome'; // for fa compatibility
389
+ hamburger.dataset.pos = String(pos);
390
+ wrapper.appendChild(hamburger);
391
+ return wrapper;
392
+ }, {
393
+ key: `float-icon-${node.attrs?.objectId ?? pos}`,
394
+ side: 1,
395
+ }));
349
396
  const decoFlags = node.attrs?.isDeco;
350
397
  if (!decoFlags)
351
398
  return;
352
399
  if (decoFlags.isSlice || decoFlags.isTag || decoFlags.isComment) {
353
- // --- Container for gutter marks ---
354
- const container = document.createElement('span');
355
- container.style.position = 'absolute';
356
- container.style.left = '27px';
357
- container.style.display = 'inline-flex';
358
- container.style.gap = '6px';
359
- container.style.alignItems = 'center';
360
- container.contentEditable = 'false';
361
- container.style.userSelect = 'none';
362
- // --- Slice ---
363
- if (decoFlags.isSlice) {
364
- const SliceMark = document.createElement('span');
365
- SliceMark.id = `slicemark-${uuidv4()}`;
366
- SliceMark.style.fontFamily = 'FontAwesome';
367
- SliceMark.innerHTML = '&#xf097';
368
- SliceMark.onclick = () => { };
369
- container.appendChild(SliceMark);
370
- }
371
- // --- Tag ---
372
- if (decoFlags.isTag) {
373
- const TagMark = document.createElement('span');
374
- TagMark.style.fontFamily = 'FontAwesome';
375
- TagMark.innerHTML = '&#xf02b;';
376
- TagMark.onclick = () => { };
377
- container.appendChild(TagMark);
378
- }
379
- // --- Comment ---
380
- if (decoFlags.isComment) {
381
- const CommentMark = document.createElement('span');
382
- CommentMark.style.fontFamily = 'FontAwesome';
383
- CommentMark.innerHTML = '&#xf075;';
384
- CommentMark.onclick = () => { };
385
- container.appendChild(CommentMark);
386
- }
387
- decorations.push(Decoration.widget(pos + 1, container, { side: -1 }));
400
+ decorations.push(Decoration.widget(pos + 1, () => {
401
+ const container = document.createElement('span');
402
+ container.style.position = 'absolute';
403
+ container.style.left = '27px';
404
+ container.style.display = 'inline-flex';
405
+ container.style.gap = '6px';
406
+ container.style.alignItems = 'center';
407
+ container.contentEditable = 'false';
408
+ container.style.userSelect = 'none';
409
+ if (decoFlags.isSlice) {
410
+ const sliceMark = document.createElement('span');
411
+ sliceMark.style.fontFamily = 'FontAwesome';
412
+ sliceMark.innerHTML = '&#xf097';
413
+ sliceMark.onclick = () => { };
414
+ container.appendChild(sliceMark);
415
+ }
416
+ if (decoFlags.isTag) {
417
+ const tagMark = document.createElement('span');
418
+ tagMark.style.fontFamily = 'FontAwesome';
419
+ tagMark.innerHTML = '&#xf02b;';
420
+ tagMark.onclick = () => { };
421
+ container.appendChild(tagMark);
422
+ }
423
+ if (decoFlags.isComment) {
424
+ const commentMark = document.createElement('span');
425
+ commentMark.style.fontFamily = 'FontAwesome';
426
+ commentMark.innerHTML = '&#xf075;';
427
+ commentMark.onclick = () => { };
428
+ container.appendChild(commentMark);
429
+ }
430
+ return container;
431
+ }, {
432
+ key: `float-marks-${node.attrs?.objectId ?? pos}-${Number(!!decoFlags.isSlice)}${Number(!!decoFlags.isTag)}${Number(!!decoFlags.isComment)}`,
433
+ side: -1,
434
+ }));
388
435
  }
389
436
  });
390
437
  return DecorationSet.create(state.doc, decorations);
@@ -426,46 +473,60 @@ export function positionAboveOrBelow(anchorRect, bodyRect) {
426
473
  h: Math.round(menuH),
427
474
  };
428
475
  }
429
- export function openFloatingMenu(plugin, view, pos, anchorEl, contextPos) {
430
- // Close existing popup if any
476
+ export function createMenuCallbacks(view, plugin, hasClipboard = true, hasPM = true) {
477
+ return {
478
+ enableCopy: () => !view.state.selection.empty,
479
+ enablePaste: () => hasClipboard,
480
+ enablePasteAsReference: () => hasPM,
481
+ enableCitationAndComment: () => !view.state.selection.empty,
482
+ enableTagAndInfoicon: () => true,
483
+ copyRich: () => copySelectionRich(view, plugin),
484
+ copyPlain: () => copySelectionPlain(view, plugin),
485
+ paste: () => pasteFromClipboard(view, plugin),
486
+ pastePlain: () => pasteAsPlainText(view, plugin),
487
+ pasteAsReference: () => pasteAsReference(view, plugin),
488
+ createCitation: () => createCitationHandler(view),
489
+ createInfoIcon: () => createInfoIconHandler(view),
490
+ createSlice: () => createNewSlice(view),
491
+ showReferences: () => showReferences(view),
492
+ addComment: () => { },
493
+ addTag: () => { },
494
+ };
495
+ }
496
+ export function closeExistingPopup(plugin) {
431
497
  if (plugin._popUpHandle) {
432
498
  plugin._popUpHandle.close(null);
433
- plugin._popUpHandle = null;
434
499
  }
435
- // Determine if clipboard has ProseMirror data
500
+ }
501
+ export function createOnCloseHandler(plugin, anchorEl) {
502
+ return () => {
503
+ plugin._popUpHandle = null;
504
+ anchorEl?.closest('.pm-hamburger-wrapper')?.classList.remove('popup-open');
505
+ };
506
+ }
507
+ export function openFloatingMenu(plugin, view, pos, anchorEl, contextPos) {
508
+ closeExistingPopup(plugin);
436
509
  Promise.all([clipboardHasProseMirrorData(), clipboardHasData()])
437
- .then(([hasPM, clipboardHasData]) => {
438
- plugin._popUpHandle = createPopUp(FloatingMenu, {
510
+ .then(([hasPM, hasClipboard]) => {
511
+ const ctx = {
439
512
  editorState: view.state,
440
- editorView: view,
441
513
  paragraphPos: pos,
442
- pasteAsReferenceEnabled: hasPM,
443
- enablePasteAsPlainText: clipboardHasData,
444
- copyRichHandler: () => copySelectionRich(view, plugin),
445
- copyPlainHandler: () => copySelectionPlain(view, plugin),
446
- pasteHandler: () => pasteFromClipboard(view, plugin),
447
- pasteAsReferenceHandler: () => pasteAsReference(view, plugin),
448
- pastePlainHandler: () => pasteAsPlainText(view, plugin),
449
- createInfoIconHandler: () => createInfoIconHandler(view),
450
- createCitationHandler: () => createCitationHandler(view),
451
- createNewSliceHandler: () => createNewSlice(view),
452
- showReferencesHandler: () => showReferences(view),
514
+ };
515
+ const items = plugin.menuItems ??
516
+ getDefaultMenuItems(createMenuCallbacks(view, plugin, hasClipboard, hasPM));
517
+ plugin._popUpHandle = createPopUp(FloatingMenu, {
518
+ context: ctx,
519
+ items,
453
520
  }, {
454
521
  anchor: anchorEl || view.dom,
455
- contextPos: contextPos,
522
+ contextPos,
456
523
  position: positionAboveOrBelow,
457
524
  autoDismiss: false,
458
- onClose: () => {
459
- plugin._popUpHandle = null;
460
- // Remove 'popup-open' class if anchor is a hamburger wrapper
461
- anchorEl
462
- ?.closest('.pm-hamburger-wrapper')
463
- ?.classList.remove('popup-open');
464
- },
525
+ onClose: createOnCloseHandler(plugin, anchorEl),
465
526
  });
466
527
  })
467
- .catch(err => {
468
- console.error('Failed to check clipboard data:', err);
528
+ .catch((err) => {
529
+ console.error('Failed to open floating menu:', err);
469
530
  });
470
531
  }
471
532
  export function addAltRightClickHandler(view, plugin) {
@@ -496,6 +557,8 @@ export function changeAttribute(_view) {
496
557
  const node = _view.state.doc.nodeAt(from);
497
558
  if (!node)
498
559
  return; // early return if node does not exist
560
+ if (node.attrs?.isDeco?.isSlice)
561
+ return;
499
562
  let tr = _view.state.tr;
500
563
  const newattrs = { ...node.attrs };
501
564
  const isDeco = { ...newattrs.isDeco };
@@ -3,24 +3,12 @@
3
3
  * @copyright Copyright 2025 Modus Operandi Inc. All Rights Reserved.
4
4
  */
5
5
  import React from 'react';
6
- import { EditorState } from 'prosemirror-state';
6
+ import { FloatingMenuItem, FloatingMenuContext } from './model';
7
7
  interface FloatingMenuProps {
8
- editorState: EditorState;
9
- paragraphPos: number;
10
- pasteAsReferenceEnabled: boolean;
11
- enablePasteAsPlainText: boolean;
12
- copyRichHandler: () => void;
13
- copyPlainHandler: () => void;
14
- createCitationHandler: () => void;
15
- createInfoIconHandler: () => void;
16
- pasteHandler: () => void;
17
- pasteAsReferenceHandler: () => void;
18
- pastePlainHandler: () => void;
19
- createNewSliceHandler: () => void;
20
- showReferencesHandler: () => void;
8
+ context: FloatingMenuContext;
9
+ items: FloatingMenuItem[];
21
10
  }
22
- export declare class FloatingMenu extends React.PureComponent<FloatingMenuProps, FloatingMenuProps> {
23
- constructor(props: any);
11
+ export declare class FloatingMenu extends React.PureComponent<FloatingMenuProps> {
24
12
  render(): React.ReactNode;
25
13
  }
26
14
  export {};
package/FloatingPopup.js CHANGED
@@ -5,41 +5,14 @@
5
5
  import React from 'react';
6
6
  import { CustomButton } from '@modusoperandi/licit-ui-commands';
7
7
  export class FloatingMenu extends React.PureComponent {
8
- constructor(props) {
9
- super(props);
10
- this.state = {
11
- ...props,
12
- };
13
- }
14
8
  render() {
15
- const { editorState, paragraphPos } = this.props;
16
- const { selection } = editorState;
17
- const $from = selection.$from;
18
- const $to = selection.$to;
19
- const inThisParagraph = $from.before($from.depth) === paragraphPos &&
20
- $to.before($to.depth) === paragraphPos;
21
- const isTextSelected = inThisParagraph && !selection.empty;
22
- const enableCitationAndComment = isTextSelected;
23
- const enableTagAndInfoicon = inThisParagraph;
24
- const enableCopy = !selection.empty;
9
+ const { context, items } = this.props;
25
10
  return (React.createElement("div", { className: "context-menu", role: "menu", tabIndex: -1 },
26
- React.createElement("div", { className: "context-menu__items" },
27
- React.createElement(CustomButton, { disabled: !enableCitationAndComment, label: "Create Citation", onClick: this.props.createCitationHandler }),
28
- React.createElement(CustomButton, { disabled: !enableTagAndInfoicon, label: "Create Infoicon", onClick: this.props.createInfoIconHandler }),
29
- React.createElement(CustomButton, { disabled: !enableCopy, label: "Copy(Ctrl + C)", onClick: this.props.copyRichHandler }),
30
- React.createElement(CustomButton, { disabled: !enableCopy, label: "Copy Without Formatting", onClick: this.props.copyPlainHandler }),
31
- React.createElement(CustomButton, { disabled: !this.props.enablePasteAsPlainText, label: "Paste(Ctrl + V)", onClick: () => {
32
- this.props.pasteHandler();
33
- } }),
34
- React.createElement(CustomButton, { disabled: !this.props.enablePasteAsPlainText, label: "Paste As Plain Text", onClick: this.props.pastePlainHandler }),
35
- React.createElement(CustomButton, { disabled: !this.props.pasteAsReferenceEnabled, label: "Paste As Reference(Ctrl + Alt + V)", onClick: () => {
36
- this.props.pasteAsReferenceHandler();
37
- } }),
38
- React.createElement(CustomButton, { label: "Create Bookmark", onClick: () => {
39
- this.props.createNewSliceHandler();
40
- } }),
41
- React.createElement(CustomButton, { label: "Insert Reference", onClick: () => {
42
- this.props.showReferencesHandler();
43
- } }))));
11
+ React.createElement("div", { className: "context-menu__items" }, items.map((item) => {
12
+ const enabled = item.isEnabled
13
+ ? item.isEnabled(context)
14
+ : true;
15
+ return (React.createElement(CustomButton, { key: item.id, label: item.label, disabled: !enabled, onClick: item.onClick }));
16
+ }))));
44
17
  }
45
18
  }
package/model.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * @license MIT
3
3
  * @copyright Copyright 2025 Modus Operandi Inc. All Rights Reserved.
4
4
  */
5
+ import { EditorState } from 'prosemirror-state';
5
6
  export interface SliceModel {
6
7
  name: string;
7
8
  description: string;
@@ -19,3 +20,13 @@ export interface FloatRuntime {
19
20
  insertCitationFloat(): void;
20
21
  insertReference(): Promise<SliceModel>;
21
22
  }
23
+ export interface FloatingMenuContext {
24
+ editorState: EditorState;
25
+ paragraphPos?: number;
26
+ }
27
+ export interface FloatingMenuItem {
28
+ id: string;
29
+ label: string;
30
+ onClick: () => void;
31
+ isEnabled?: (ctx: FloatingMenuContext) => boolean;
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modusoperandi/licit-floatingmenu",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "licit-floatingmenu plugin built with ProseMirror",
6
6
  "main": "index.js",
@@ -41,18 +41,18 @@
41
41
  },
42
42
  "devDependencies": {
43
43
  "@cfaester/enzyme-adapter-react-18": "^0.8.0",
44
- "@modusoperandi/eslint-config": "^3.0.3",
44
+ "@modusoperandi/eslint-config": "^3.0.4",
45
45
  "@testing-library/jest-dom": "^6.9.1",
46
- "@testing-library/react": "^16.3.1",
46
+ "@testing-library/react": "^16.3.2",
47
47
  "@testing-library/user-event": "^14.6.1",
48
48
  "@types/jest": "^29.5.14",
49
- "@types/node": "^20.19.28",
49
+ "@types/node": "^20.19.37",
50
50
  "@types/orderedmap": "^2.0.0",
51
- "@types/react": "^18.3.27",
51
+ "@types/react": "^18.3.28",
52
52
  "@types/react-dom": "^18.3.7",
53
53
  "copyfiles": "^2.4.1",
54
54
  "enzyme": "^3.11.0",
55
- "eslint": "^9.39.2",
55
+ "eslint": "^9.39.4",
56
56
  "husky": "^9.1.7",
57
57
  "identity-obj-proxy": "^3.0.0",
58
58
  "jest": "^29.7.0",
@@ -62,7 +62,7 @@
62
62
  "jest-prosemirror": "^2.2.0",
63
63
  "jest-sonar-reporter": "^2.0.0",
64
64
  "lint-staged": "^15.5.2",
65
- "prettier": "^3.7.4",
65
+ "prettier": "^3.8.1",
66
66
  "stylelint-config-standard": "^36.0.1",
67
67
  "stylelint-prettier": "^5.0.3",
68
68
  "ts-jest": "^29.4.6",
package/slice.js CHANGED
@@ -2,13 +2,33 @@
2
2
  * @license MIT
3
3
  * @copyright Copyright 2025 Modus Operandi Inc. All Rights Reserved.
4
4
  */
5
+ function getSliceCacheKey(slice) {
6
+ return [
7
+ slice.id ?? '',
8
+ slice.source ?? '',
9
+ slice.from ?? '',
10
+ slice.to ?? '',
11
+ slice.referenceType ?? '',
12
+ ].join('::');
13
+ }
14
+ function dedupeSlices(slices) {
15
+ const seen = new Set();
16
+ return slices.filter((slice) => {
17
+ const key = getSliceCacheKey(slice);
18
+ if (seen.has(key)) {
19
+ return false;
20
+ }
21
+ seen.add(key);
22
+ return true;
23
+ });
24
+ }
5
25
  export function createSliceManager(runtime) {
6
26
  let docSlices = [];
7
27
  // store slices in cache
8
28
  function setSlices(slices, state) {
9
29
  const objectId = state.doc.attrs.objectId;
10
30
  const filteredSlices = slices.filter((slice) => slice.source === objectId);
11
- docSlices = [...docSlices, ...filteredSlices];
31
+ docSlices = dedupeSlices([...docSlices, ...filteredSlices]);
12
32
  }
13
33
  function getDocSlices() {
14
34
  return docSlices;
@@ -19,25 +39,35 @@ export function createSliceManager(runtime) {
19
39
  }
20
40
  // add new slice to cache
21
41
  function addSliceToList(slice) {
22
- docSlices.push(slice);
42
+ docSlices = dedupeSlices([...docSlices, slice]);
23
43
  return docSlices;
24
44
  }
25
45
  // apply slice attributes to the doc
26
46
  function setSliceAttrs(view) {
27
47
  const result = getDocSlices();
48
+ const sliceIds = new Set(result.map((slice) => slice.from).filter(Boolean));
49
+ if (sliceIds.size === 0) {
50
+ return;
51
+ }
28
52
  let tr = view.state.tr;
29
- for (const obj of result) {
30
- view.state.doc.descendants((nodeactual, pos) => {
31
- if (nodeactual?.attrs?.objectId === obj?.from) {
32
- const newattrs = { ...nodeactual.attrs };
33
- const isDeco = { ...newattrs.isDeco };
34
- isDeco.isSlice = true;
35
- newattrs.isDeco = isDeco;
36
- tr = tr.setNodeMarkup(pos, undefined, newattrs);
37
- }
38
- });
53
+ let hasChanges = false;
54
+ view.state.doc.descendants((nodeactual, pos) => {
55
+ if (!sliceIds.has(nodeactual?.attrs?.objectId)) {
56
+ return;
57
+ }
58
+ if (nodeactual.attrs?.isDeco?.isSlice) {
59
+ return;
60
+ }
61
+ const newattrs = { ...nodeactual.attrs };
62
+ const isDeco = { ...newattrs.isDeco };
63
+ isDeco.isSlice = true;
64
+ newattrs.isDeco = isDeco;
65
+ tr = tr.setNodeMarkup(pos, undefined, newattrs);
66
+ hasChanges = true;
67
+ });
68
+ if (hasChanges) {
69
+ view.dispatch(tr);
39
70
  }
40
- view.dispatch(tr);
41
71
  }
42
72
  function addInfoIcon() {
43
73
  return runtime?.insertInfoIconFloat();
@@ -60,6 +90,6 @@ export function createSliceManager(runtime) {
60
90
  addInfoIcon,
61
91
  addCitation,
62
92
  createSliceViaDialog,
63
- insertReference
93
+ insertReference,
64
94
  };
65
95
  }
package/ui/menu.css CHANGED
@@ -7,7 +7,6 @@
7
7
  /* Icon hidden by default */
8
8
  .float-icon {
9
9
  position: absolute;
10
- right: 0;
11
10
  top: 0;
12
11
  cursor: pointer;
13
12
  margin-right: 6px;