Feature Request:Add a context menu
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.
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();
});
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();
});
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.
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.
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.
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.