hollama icon indicating copy to clipboard operation
hollama copied to clipboard

Feature Request:Add a context menu

Open ZeroDot1 opened this issue 1 year ago • 6 comments

Hey, that's a good application, but it should have a Context Menu with the following functions to make the app more user-friendly for many users.

The required features are:

  • Copy: Allow users to copy the selected text
  • Cut: Allow users to cut the selected text (i.e., remove it from the current location and move it to the clipboard)
  • Paste: Allow users to paste the content into a new location
  • Save as [e.g. .txt file]: Allow users to save the marked text as a file in a specific format, such as a plain text file

This Context Menu would greatly enhance the usability of the application for many users.

ZeroDot1 avatar Dec 20 '24 10:12 ZeroDot1

interface ContextMenuItem {
  label: string;
  onClick: () => void;
  disabled?: boolean; // Optional: Disabled state for menu items
}

class ContextMenu {
  private menu: HTMLDivElement;

  constructor() {
    this.menu = document.createElement('div');
    this.menu.id = 'context-menu';
    this.menu.style.cssText = `
      position: fixed;
      background-color: white;
      border: 1px solid #ccc;
      padding: 5px;
      display: none;
      z-index: 1000; // Ensure it's on top
      box-shadow: 2px 2px 5px rgba(0,0,0,0.2); // Add a subtle shadow
    `; // Use cssText for more efficient styling

    document.body.appendChild(this.menu);

    // Close on outside click
    document.addEventListener('click', this.handleDocumentClick);

    // Prevent menu from closing when clicking inside
    this.menu.addEventListener('click', (event) => event.stopPropagation());
  }

  private handleDocumentClick = (event: MouseEvent) => { // Explicit type
    if (!this.menu.contains(event.target as Node)) {
      this.hide();
    }
  };

  show(x: number, y: number, items: ContextMenuItem[]) {
    this.menu.innerHTML = ''; // Clear previous items

    items.forEach(item => {
      const menuItem = document.createElement('div');
      menuItem.textContent = item.label;
      menuItem.style.cssText = `
        padding: 3px 5px;
        cursor: pointer;
        ${item.disabled ? 'color: #aaa; cursor: default;' : ''} // Style disabled items
      `;
      if (!item.disabled) { // Only add listener if not disabled
        menuItem.addEventListener('click', item.onClick);
      } else {
          menuItem.style.cursor = 'default';
      }
      this.menu.appendChild(menuItem);
    });

    this.menu.style.left = x + 'px';
    this.menu.style.top = y + 'px';
    this.menu.style.display = 'block';
  }

  hide() {
    this.menu.style.display = 'none';
  }

  destroy() {
    document.removeEventListener('click', this.handleDocumentClick);
    this.menu.remove(); // Important: Remove from DOM to prevent memory leaks
  }
}

// Example usage:
const contextMenu = new ContextMenu();

document.addEventListener('contextmenu', (event) => {
  event.preventDefault();

  const selectedText = window.getSelection()?.toString() || '';
    const hasSelection = selectedText.length > 0;

  const menuItems: ContextMenuItem[] = [
    {
      label: 'Copy',
      onClick: () => {
        navigator.clipboard.writeText(selectedText).catch(console.error);
        contextMenu.hide();
      },
      disabled: !hasSelection
    },
    {
      label: 'Paste',
      onClick: async () => {
        try {
          const text = await navigator.clipboard.readText();
            const focusedElement = document.activeElement as HTMLInputElement | HTMLTextAreaElement;
            if (focusedElement) {
                focusedElement.value += text;
            } else {
                alert("No focusable element found to paste into.");
            }
        } catch (error) {
          console.error("Paste failed:", error);
            alert("Error on paste. Check console."); // Provide user feedback
        }
        contextMenu.hide();
      }
    },
    {
      label: 'Cut',
      onClick: () => {
        navigator.clipboard.writeText(selectedText).then(() => {
            if (window.getSelection) {
                const sel = window.getSelection();
                if (sel.rangeCount) {
                    sel.deleteFromDocument();
                }
            }
        }).catch(console.error);
        contextMenu.hide();
      },
      disabled: !hasSelection
    }
  ];

  contextMenu.show(event.clientX, event.clientY, menuItems);
});

// Important: When you're done with the context menu (e.g., on page unload), call destroy:
window.addEventListener('beforeunload', () => {
    contextMenu.destroy();
});

ZeroDot1 avatar Dec 21 '24 15:12 ZeroDot1

interface ContextMenuItem {
    label: string;
    onClick: () => void;
    disabled?: boolean;
}

class ContextMenu {
    private menu: HTMLDivElement;

    constructor() {
        this.menu = document.createElement('div');
        this.menu.id = 'context-menu';
        this.menu.style.cssText = `
            position: fixed;
            background-color: white;
            border: 1px solid #ccc;
            padding: 5px;
            display: none;
            z-index: 1000;
            box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
        `;

        document.body.appendChild(this.menu);
        document.addEventListener('click', this.handleDocumentClick);
        this.menu.addEventListener('click', (event) => event.stopPropagation());
    }

    private handleDocumentClick = (event: MouseEvent) => {
        if (!this.menu.contains(event.target as Node)) {
            this.hide();
        }
    };

    show(x: number, y: number, items: ContextMenuItem[]) {
        this.menu.innerHTML = '';

        items.forEach(item => {
            const menuItem = document.createElement('div');
            menuItem.textContent = item.label;
            menuItem.style.cssText = `
                padding: 3px 5px;
                cursor: pointer;
                ${item.disabled ? 'color: #aaa; cursor: default;' : ''}
            `;
            if (!item.disabled) {
                menuItem.addEventListener('click', item.onClick);
            }
            this.menu.appendChild(menuItem);
        });

        this.menu.style.left = x + 'px';
        this.menu.style.top = y + 'px';
        this.menu.style.display = 'block';
    }

    hide() {
        this.menu.style.display = 'none';
    }

    destroy() {
        document.removeEventListener('click', this.handleDocumentClick);
        this.menu.remove();
    }
}

const contextMenu = new ContextMenu();

document.addEventListener('contextmenu', (event) => {
    event.preventDefault();

    const selectedText = window.getSelection()?.toString() || '';
    const hasSelection = selectedText.length > 0;

    const menuItems: ContextMenuItem[] = [
        {
            label: 'Copy',
            onClick: () => {
                navigator.clipboard.writeText(selectedText).catch(console.error);
                contextMenu.hide();
            },
            disabled: !hasSelection
        },
        {
            label: 'Paste',
            onClick: async () => {
                try {
                    const text = await navigator.clipboard.readText();
                    const focusedElement = document.activeElement as HTMLInputElement | HTMLTextAreaElement;
                    if (focusedElement) {
                        focusedElement.value += text;
                    } else {
                        alert("No focusable element found to paste into.");
                    }
                } catch (error) {
                    console.error("Paste failed:", error);
                    alert("Error on paste. Check console.");
                }
                contextMenu.hide();
            }
        },
        {
            label: 'Cut',
            onClick: () => {
                navigator.clipboard.writeText(selectedText).then(() => {
                    if (window.getSelection) {
                        const sel = window.getSelection();
                        if (sel.rangeCount) {
                            sel.deleteFromDocument();
                        }
                    }
                }).catch(console.error);
                contextMenu.hide();
            },
            disabled: !hasSelection
        },
        {
            label: 'Save',
            onClick: () => {
                if (hasSelection) {
                    const blob = new Blob([selectedText], { type: 'text/plain' });
                    const url = URL.createObjectURL(blob);
                    const link = document.createElement('a');
                    link.href = url;
                    link.download = 'selected_text.txt'; // Dateiname
                    link.click();
                    URL.revokeObjectURL(url); // Speicher freigeben
                }
                contextMenu.hide();
            },
            disabled: !hasSelection
        }
    ];

    contextMenu.show(event.clientX, event.clientY, menuItems);
});

window.addEventListener('beforeunload', () => {
    contextMenu.destroy();
});

ZeroDot1 avatar Dec 21 '24 15:12 ZeroDot1

I'm not entirely sure why we need dedicated buttons for cut, copy & paste. The browser already has built-in context menus and keyboard shortcuts for those actions.

fmaclen avatar Dec 21 '24 17:12 fmaclen

I'm not entirely sure why we need dedicated buttons for cut, copy & paste. The browser already has built-in context menus and keyboard shortcuts for those actions.

Oh, the context menu is only needed for the packaged version. If I install the application e.g. in Arch Linux or Windows and right click, there is no context menu.

ZeroDot1 avatar Dec 21 '24 17:12 ZeroDot1

Oh, I see. Thanks for the clarification.

Given the complexity of this change I think we would be better off using an existing library to handle this, such as electron-context-menu. We'll also need to figure out a way to handle i18n which is currently handled at the app level (SvelteKit) separately from the packaged release.

That being said, since keyboard shortcuts do appear to work as expected (and since we also have a bunch of dedicated Copy buttons already) we'll probably punt on this improvement for later.

PS: Another workaround you can try in the meantime, while the packaged app is running, you can visit http://localhost:4173 on any browser and access Hollama that way, which should have all of the typical context menus.

fmaclen avatar Dec 23 '24 14:12 fmaclen

I tried launching Hollama as an application (I'm using Hollama with Arch Linux), and when I open it in the browser, none of my sessions or knowledge is displayed. I have to set everything up again.

ZeroDot1 avatar Dec 25 '24 13:12 ZeroDot1