Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Request:Add a context menu #252

Open
ZeroDot1 opened this issue Dec 20, 2024 · 6 comments
Open

Feature Request:Add a context menu #252

ZeroDot1 opened this issue Dec 20, 2024 · 6 comments

Comments

@ZeroDot1
Copy link

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
Copy link
Author

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
Copy link
Author

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();
});

@fmaclen
Copy link
Owner

fmaclen commented Dec 21, 2024

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.

@ZeroDot1
Copy link
Author

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.

@fmaclen
Copy link
Owner

fmaclen commented Dec 23, 2024

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.

@ZeroDot1
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants