components_project_context_menu.js
/**
* A context menu for project management operations (rename, new, duplicate,
* delete, make-local, upload). When no project is provided only the "New
* project" item is shown.
*/
export class ProjectContextMenu {
/**
* @param {number} x - preferred left position in viewport pixels
* @param {number} y - preferred top position in viewport pixels
* @param {object|null} project - project the menu acts on, or null for a general menu
* @param {object} callbacks - callback functions for each menu action
* @param {function} [callbacks.onDelete] - called when "Delete" is clicked
* @param {function} [callbacks.onDuplicate] - called when "Duplicate" is clicked
* @param {function} [callbacks.onNew] - called when "New project" is clicked
* @param {function} [callbacks.onOpen] - called when "Open local project" is clicked
* @param {function} [callbacks.onRename] - called when "Rename" is clicked
* @param {function|null} [callbacks.onUpload] - called when "Upload changes" is clicked; omit to hide item
* @param {function|null} [callbacks.onDownload] - called when "Download project" is clicked; omit to hide item
* @param {function|null} [callbacks.onRevert] - called when "Revert changes" is clicked; omit to hide item
* @param {function|null} [callbacks.onMoveToFolder] - called when "Move to folder" is clicked; omit to hide item
* @param {function|null} [callbacks.onNewFolder] - called when "New folder" is clicked; omit to hide item
* @param {function|null} [callbacks.onPresentation] - called when "Open Presentation" is clicked; omit to hide item
* @param {function|null} [callbacks.onShare] - called when "Share…" is clicked; omit to hide item
* @param {function|null} [callbacks.onCompress] - called when "Compress…" is clicked; omit to hide item
* @param {function} [callbacks.onDismiss] - called when the menu is dismissed via outside click
*/
constructor(x, y, project, { onDelete, onDuplicate, onNew, onOpen, onRename, onUpload, onDownload, onRevert, onMoveToFolder, onNewFolder, onPresentation, onShare, onCompress, onDismiss }) {
this.project = project;
this._onDelete = onDelete;
this._onDuplicate = onDuplicate ?? (() => {});
this._onNew = onNew ?? (() => {});
this._onOpen = onOpen ?? (() => {});
this._onRename = onRename;
this._onUpload = onUpload;
this._onDownload = onDownload;
this._onRevert = onRevert;
this._onMoveToFolder = onMoveToFolder;
this._onNewFolder = onNewFolder;
this._onPresentation = onPresentation;
this._onShare = onShare;
this._onCompress = onCompress;
this._onDismiss = onDismiss ?? (() => {});
this.root = document.createElement('div');
this.root.className = 'ctx-menu';
// this.#buildInfoBlock();
this.#buildControlsBlock();
document.body.appendChild(this.root);
this.#position(x, y);
this.#bindOutsideClick();
}
/** Removes the context menu from the DOM. */
close() {
this.root.remove();
}
/** Builds the controls block with the appropriate action items for the current project. */
#buildControlsBlock() {
const controls = document.createElement('div');
controls.className = 'ctx-controls';
this.controlsBlock = controls;
// If there is no project selected, just add the "New" button
if (this.project) {
if (this._onRename) this.controlsBlock.appendChild(this.#buildRename());
this.controlsBlock.appendChild(this.#buildDuplicate());
if (this._onDelete) this.controlsBlock.appendChild(this.#buildDelete());
if (this._onUpload) {
this.controlsBlock.appendChild(this.#buildUpload());
}
if (this._onDownload) {
this.controlsBlock.appendChild(this.#buildDownload());
}
if (this._onRevert) {
this.controlsBlock.appendChild(this.#buildRevert());
}
if (this._onMoveToFolder) {
this.controlsBlock.appendChild(this.#buildMoveToFolder());
}
if (this._onCompress) {
this.controlsBlock.appendChild(this.#buildCompress());
}
if (this._onPresentation || this._onShare) {
this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
if (this._onPresentation) this.controlsBlock.appendChild(this.#buildPresentation());
if (this._onShare) this.controlsBlock.appendChild(this.#buildShare());
}
const projectId = this.project.id ?? this.project.projectId;
if (projectId) {
this.controlsBlock.appendChild(this.#buildIdLine(projectId));
}
this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
this.controlsBlock.appendChild(this.#buildNew());
this.controlsBlock.appendChild(this.#buildOpen());
if (this._onNewFolder) {
this.controlsBlock.appendChild(this.#buildNewFolder());
}
} else {
this.controlsBlock.appendChild(this.#buildNew());
this.controlsBlock.appendChild(this.#buildOpen());
if (this._onNewFolder) {
this.controlsBlock.appendChild(this.#buildNewFolder());
}
}
this.root.appendChild(this.controlsBlock);
}
/** @returns {HTMLElement} the "Rename" menu item */
#buildRename() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">✎</span><span class="ctx-item-inner">Rename</span>';
item.addEventListener('click', this._onRename);
return item;
}
/** @returns {HTMLElement} the "Open local project" menu item */
#buildOpen() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon"><span class="icon icon-open-project" style="width:14px;height:14px;"></span></span><span class="ctx-item-inner">Open local project</span>';
item.addEventListener('click', this._onOpen);
return item;
}
/** @returns {HTMLElement} the "New project" menu item */
#buildNew() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">+</span><span class="ctx-item-inner">New project</span>';
item.addEventListener('click', this._onNew);
return item;
}
/** @returns {HTMLElement} the "Duplicate" menu item */
#buildDuplicate() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">⧉</span><span class="ctx-item-inner">Duplicate</span>';
item.addEventListener('click', this._onDuplicate);
return item;
}
/** @returns {HTMLElement} the "Delete" menu item */
#buildDelete() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">🗑</span><span class="ctx-item-inner">Delete</span>';
item.addEventListener('click', this._onDelete);
return item;
}
/** @returns {HTMLElement} the "Upload changes" menu item */
#buildUpload() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">⇧</span><span class="ctx-item-inner">Upload changes</span>';
item.addEventListener('click', this._onUpload);
return item;
}
/** @returns {HTMLElement} the "Download project" menu item */
#buildDownload() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">⇩</span><span class="ctx-item-inner">Download project</span>';
item.addEventListener('click', this._onDownload);
return item;
}
/** @returns {HTMLElement} the "Revert changes" menu item */
#buildRevert() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">↺</span><span class="ctx-item-inner">Revert changes</span>';
item.title = 'Discard all changes since the last save';
item.addEventListener('click', this._onRevert);
return item;
}
/** @returns {HTMLElement} the "New folder" menu item */
#buildNewFolder() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon"><span class="icon icon-new-folder" style="width:13px;height:13px;"></span></span><span class="ctx-item-inner">New folder</span>';
item.addEventListener('click', this._onNewFolder);
return item;
}
/**
* @param {string} id - The project ID to display.
* @returns {HTMLElement} a dim ID display line
*/
#buildIdLine(id) {
const el = document.createElement('div');
el.className = 'ctx-id-line';
el.textContent = id;
return el;
}
/** @returns {HTMLElement} the "Open Presentation" menu item */
#buildPresentation() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">▶</span><span class="ctx-item-inner">Open Presentation</span>';
item.addEventListener('click', this._onPresentation);
return item;
}
/** @returns {HTMLElement} the "Share" menu item */
#buildShare() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">🔗</span><span class="ctx-item-inner">Share…</span>';
item.addEventListener('click', this._onShare);
return item;
}
/** @returns {HTMLElement} the "Compress…" menu item */
#buildCompress() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">⊜</span><span class="ctx-item-inner">Compress…</span>';
item.title = 'Remove the original audio file, keeping only the generated MP3';
item.addEventListener('click', this._onCompress);
return item;
}
/** @returns {HTMLElement} the "Move to folder" menu item */
#buildMoveToFolder() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon"><span class="icon icon-folder" style="width:13px;height:13px;"></span></span><span class="ctx-item-inner">Move to folder…</span>';
item.title = 'Move this project to a different folder';
item.addEventListener('click', this._onMoveToFolder);
return item;
}
/**
* Positions the menu at (x, y), then clamps it to stay within the viewport.
* @param {number} x - preferred left position in viewport pixels
* @param {number} y - preferred top position in viewport pixels
*/
#position(x, y) {
this.root.style.left = x + 'px';
this.root.style.top = y + 'px';
requestAnimationFrame(() => {
const mr = this.root.getBoundingClientRect();
if (x + mr.width > window.innerWidth) {
this.root.style.left = (window.innerWidth - mr.width - 8) + 'px';
}
if (y + mr.height > window.innerHeight) {
this.root.style.top = (window.innerHeight - mr.height - 8) + 'px';
}
});
}
/** Registers a mousedown listener that dismisses the menu on outside clicks. */
#bindOutsideClick() {
setTimeout(() => {
const outside = (e) => {
if (!this.root.contains(e.target)) {
this._onDismiss();
document.removeEventListener('mousedown', outside);
}
};
document.addEventListener('mousedown', outside);
}, 0);
}
}