A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang. (PERSONAL FORK)
at lambda-fork/main 427 lines 18 kB view raw
1import {getEventName, updateHotkeyTip} from "../protyle/util/compatibility"; 2import {setPosition} from "../util/setPosition"; 3import {hasClosestByClassName} from "../protyle/util/hasClosest"; 4import {isMobile} from "../util/functions"; 5import {Constants} from "../constants"; 6 7export class Menu { 8 public element: HTMLElement; 9 public data: any; // 用于记录当前菜单的数据 10 public removeCB: () => void; 11 private wheelEvent: string; 12 13 constructor() { 14 this.wheelEvent = "onwheel" in document.createElement("div") ? "wheel" : "mousewheel"; 15 16 this.element = document.getElementById("commonMenu"); 17 this.element.querySelector(".b3-menu__title .b3-menu__label").innerHTML = window.siyuan.languages.back; 18 this.element.addEventListener(isMobile() ? "click" : "mouseover", (event) => { 19 const target = event.target as Element; 20 if (isMobile()) { 21 const titleElement = hasClosestByClassName(target, "b3-menu__title"); 22 if (titleElement || (typeof event.detail === "string" && event.detail === "back")) { 23 const lastShowElements = this.element.querySelectorAll(".b3-menu__item--show"); 24 if (lastShowElements.length > 0) { 25 lastShowElements[lastShowElements.length - 1].classList.remove("b3-menu__item--show"); 26 } else { 27 this.element.style.transform = ""; 28 setTimeout(() => { 29 this.remove(); 30 }, Constants.TIMEOUT_DBLCLICK); 31 } 32 return; 33 } 34 } 35 36 const itemElement = hasClosestByClassName(target, "b3-menu__item"); 37 if (!itemElement) { 38 return; 39 } 40 if (itemElement.classList.contains("b3-menu__item--readonly")) { 41 return; 42 } 43 const subMenuElement = itemElement.querySelector(".b3-menu__submenu") as HTMLElement; 44 this.element.querySelectorAll(".b3-menu__item--show").forEach((item) => { 45 if (!item.contains(itemElement) && item !== itemElement && !itemElement.contains(item)) { 46 item.classList.remove("b3-menu__item--show"); 47 } 48 }); 49 this.element.querySelectorAll(".b3-menu__item--current").forEach((item) => { 50 item.classList.remove("b3-menu__item--current"); 51 }); 52 itemElement.classList.add("b3-menu__item--current"); 53 if (!subMenuElement) { 54 return; 55 } 56 itemElement.classList.add("b3-menu__item--show"); 57 if (!this.element.classList.contains("b3-menu--fullscreen")) { 58 this.showSubMenu(subMenuElement); 59 } 60 }); 61 } 62 63 public showSubMenu(subMenuElement: HTMLElement) { 64 const itemRect = subMenuElement.parentElement.getBoundingClientRect(); 65 subMenuElement.style.top = (itemRect.top - 8) + "px"; 66 subMenuElement.style.left = (itemRect.right + 8) + "px"; 67 subMenuElement.style.bottom = "auto"; 68 const rect = subMenuElement.getBoundingClientRect(); 69 if (rect.right > window.innerWidth) { 70 if (itemRect.left - 8 > rect.width) { 71 subMenuElement.style.left = (itemRect.left - 8 - rect.width) + "px"; 72 } else { 73 subMenuElement.style.left = (window.innerWidth - rect.width) + "px"; 74 } 75 } 76 if (rect.bottom > window.innerHeight) { 77 subMenuElement.style.top = "auto"; 78 subMenuElement.style.bottom = "8px"; 79 } 80 } 81 82 private preventDefault(event: KeyboardEvent) { 83 if (!hasClosestByClassName(event.target as Element, "b3-menu") && 84 // 移动端底部键盘菜单 85 !hasClosestByClassName(event.target as Element, "keyboard__bar")) { 86 event.preventDefault(); 87 } 88 } 89 90 public addItem(option: IMenu) { 91 const menuItem = new MenuItem(option); 92 if (menuItem) { 93 this.append(menuItem.element, option.index); 94 return menuItem.element; 95 } 96 } 97 98 public removeScrollEvent() { 99 window.removeEventListener(isMobile() ? "touchmove" : this.wheelEvent, this.preventDefault, false); 100 } 101 102 public remove(isKeyEvent = false) { 103 if (isKeyEvent) { 104 const subElements = window.siyuan.menus.menu.element.querySelectorAll(".b3-menu__item--show"); 105 if (subElements.length > 0) { 106 const subElement = subElements[subElements.length - 1]; 107 subElement.classList.remove("b3-menu__item--show"); 108 subElement.classList.add("b3-menu__item--current"); 109 subElement.querySelector(".b3-menu__item--current")?.classList.remove("b3-menu__item--current"); 110 return; 111 } 112 } 113 if (window.siyuan.menus.menu.removeCB) { 114 window.siyuan.menus.menu.removeCB(); 115 window.siyuan.menus.menu.removeCB = undefined; 116 } 117 this.removeScrollEvent(); 118 this.element.firstElementChild.classList.add("fn__none"); 119 this.element.lastElementChild.innerHTML = ""; 120 this.element.lastElementChild.removeAttribute("style"); // 输入框 focus 后 boxShadow 显示不全 121 this.element.classList.add("fn__none"); 122 this.element.classList.remove("b3-menu--list", "b3-menu--fullscreen"); 123 this.element.removeAttribute("style"); // zIndex 124 this.element.removeAttribute("data-name"); // 标识再次点击不消失 125 this.element.removeAttribute("data-from"); // 标识菜单入口 126 this.data = undefined; // 移除数据 127 } 128 129 public append(element?: HTMLElement, index?: number) { 130 if (!element) { 131 return; 132 } 133 if (typeof index === "number") { 134 const insertElement = this.element.querySelectorAll(".b3-menu__items > .b3-menu__separator")[index]; 135 if (insertElement) { 136 insertElement.before(element); 137 return; 138 } 139 } 140 this.element.lastElementChild.append(element); 141 } 142 143 public popup(options: IPosition) { 144 if (this.element.lastElementChild.innerHTML === "") { 145 return; 146 } 147 window.addEventListener(isMobile() ? "touchmove" : this.wheelEvent, this.preventDefault, {passive: false}); 148 this.element.style.zIndex = (++window.siyuan.zIndex).toString(); 149 this.element.classList.remove("fn__none"); 150 setPosition(this.element, options.x - (options.isLeft ? this.element.clientWidth : 0), options.y, options.h, options.w); 151 } 152 153 public fullscreen(position: "bottom" | "all" = "all") { 154 if (this.element.lastElementChild.innerHTML === "") { 155 return; 156 } 157 this.element.classList.add("b3-menu--fullscreen"); 158 this.element.style.zIndex = (++window.siyuan.zIndex).toString(); 159 this.element.firstElementChild.classList.remove("fn__none"); 160 this.element.classList.remove("fn__none"); 161 window.addEventListener("touchmove", this.preventDefault, {passive: false}); 162 163 setTimeout(() => { 164 if (position === "bottom") { 165 this.element.style.transform = "translateY(-50vh)"; 166 this.element.style.height = "50vh"; 167 } else { 168 this.element.style.transform = "translateY(-100%)"; 169 } 170 }); 171 this.element.lastElementChild.scrollTop = 0; 172 } 173} 174 175export class MenuItem { 176 public element: HTMLElement; 177 178 constructor(options: IMenu) { 179 if (options.ignore) { 180 return; 181 } 182 if (options.type === "empty") { 183 this.element = document.createElement("div"); 184 this.element.innerHTML = options.label; 185 if (options.bind) { 186 options.bind(this.element); 187 } 188 return; 189 } 190 191 this.element = document.createElement("button"); 192 if (options.disabled) { 193 this.element.setAttribute("disabled", "disabled"); 194 } 195 if (options.id) { 196 this.element.setAttribute("data-id", options.id); 197 } 198 if (options.type === "separator") { 199 this.element.classList.add("b3-menu__separator"); 200 return; 201 } 202 this.element.classList.add("b3-menu__item"); 203 if (options.current) { 204 this.element.classList.add("b3-menu__item--selected"); 205 } 206 if (options.click) { 207 // 需使用 click,否则移动端无法滚动 208 this.element.addEventListener("click", (event) => { 209 if (this.element.getAttribute("disabled")) { 210 return; 211 } 212 let keepOpen = options.click(this.element, event); 213 if (keepOpen instanceof Promise) { 214 keepOpen = false; 215 } 216 event.preventDefault(); 217 event.stopImmediatePropagation(); 218 event.stopPropagation(); 219 if (this.element.parentElement && !keepOpen) { 220 window.siyuan.menus.menu.remove(); 221 } 222 }); 223 } 224 if (options.type === "readonly") { 225 this.element.classList.add("b3-menu__item--readonly"); 226 } 227 if (options.icon === "iconTrashcan" || options.warning) { 228 this.element.classList.add("b3-menu__item--warning"); 229 } 230 231 if (options.element) { 232 this.element.append(options.element); 233 } else { 234 let html = `<span class="b3-menu__label">${options.label || "&nbsp;"}</span>`; 235 if (typeof options.iconHTML === "string") { 236 html = options.iconHTML + html; 237 } else { 238 html = `<svg class="b3-menu__icon ${options.iconClass || ""}" style="${options.icon === "iconClose" ? "height:10px;" : ""}"><use xlink:href="#${options.icon || ""}"></use></svg>${html}`; 239 } 240 if (options.accelerator) { 241 html += `<span class="b3-menu__accelerator b3-menu__accelerator--hotkey">${updateHotkeyTip(options.accelerator)}</span>`; 242 } 243 if (options.action) { 244 html += `<svg class="b3-menu__action${options.action === "iconCloseRound" ? " b3-menu__action--close" : ""}"><use xlink:href="#${options.action}"></use></svg>`; 245 } 246 if (options.checked) { 247 html += '<svg class="b3-menu__checked"><use xlink:href="#iconSelect"></use></svg></span>'; 248 } 249 this.element.innerHTML = html; 250 } 251 252 if (options.bind) { 253 // 主题 rem craft 需要使用 b3-menu__item--custom 来区分自定义菜单 by 281261361 254 this.element.classList.add("b3-menu__item--custom"); 255 options.bind(this.element); 256 } 257 258 if (options.submenu) { 259 const submenuElement = document.createElement("div"); 260 submenuElement.classList.add("b3-menu__submenu"); 261 submenuElement.innerHTML = '<div class="b3-menu__items"></div>'; 262 options.submenu.forEach((item) => { 263 submenuElement.firstElementChild.append(new MenuItem(item)?.element || ""); 264 }); 265 this.element.insertAdjacentHTML("beforeend", '<svg class="b3-menu__icon b3-menu__icon--small"><use xlink:href="#iconRight"></use></svg>'); 266 this.element.append(submenuElement); 267 } 268 } 269} 270 271const getActionMenu = (element: Element, next: boolean) => { 272 let actionMenuElement = element; 273 while (actionMenuElement && 274 (actionMenuElement.classList.contains("b3-menu__separator") || 275 actionMenuElement.classList.contains("b3-menu__item--readonly") || 276 // https://github.com/siyuan-note/siyuan/issues/12518 277 actionMenuElement.getBoundingClientRect().height === 0) 278 ) { 279 if (actionMenuElement.querySelector(".b3-text-field")) { 280 break; 281 } 282 if (next) { 283 actionMenuElement = actionMenuElement.nextElementSibling; 284 } else { 285 actionMenuElement = actionMenuElement.previousElementSibling; 286 } 287 } 288 return actionMenuElement; 289}; 290 291export const bindMenuKeydown = (event: KeyboardEvent) => { 292 if (window.siyuan.menus.menu.element.classList.contains("fn__none") 293 || event.altKey || event.shiftKey || event.ctrlKey || event.metaKey) { 294 return false; 295 } 296 const target = event.target as HTMLElement; 297 if (window.siyuan.menus.menu.element.contains(target) && ["INPUT", "TEXTAREA"].includes(target.tagName)) { 298 return false; 299 } 300 const eventCode = Constants.KEYCODELIST[event.keyCode]; 301 if (eventCode === "↓" || eventCode === "↑") { 302 const currentElement = window.siyuan.menus.menu.element.querySelector(".b3-menu__item--current"); 303 let actionMenuElement; 304 if (!currentElement) { 305 if (eventCode === "↑") { 306 actionMenuElement = getActionMenu(window.siyuan.menus.menu.element.lastElementChild.lastElementChild, false); 307 } else { 308 actionMenuElement = getActionMenu(window.siyuan.menus.menu.element.lastElementChild.firstElementChild, true); 309 } 310 } else { 311 currentElement.classList.remove("b3-menu__item--current", "b3-menu__item--show"); 312 if (eventCode === "↑") { 313 actionMenuElement = getActionMenu(currentElement.previousElementSibling, false); 314 if (!actionMenuElement) { 315 actionMenuElement = getActionMenu(currentElement.parentElement.lastElementChild, false); 316 } 317 } else { 318 actionMenuElement = getActionMenu(currentElement.nextElementSibling, true); 319 if (!actionMenuElement) { 320 actionMenuElement = getActionMenu(currentElement.parentElement.firstElementChild, true); 321 } 322 } 323 } 324 if (actionMenuElement) { 325 if (actionMenuElement.classList.contains("b3-menu__item")) { 326 actionMenuElement.classList.add("b3-menu__item--current"); 327 } 328 const inputElement = actionMenuElement.querySelector(":scope > .b3-text-field") as HTMLInputElement; 329 if (inputElement) { 330 inputElement.focus(); 331 } 332 actionMenuElement.classList.remove("b3-menu__item--show"); 333 const parentRect = actionMenuElement.parentElement.getBoundingClientRect(); 334 const actionMenuRect = actionMenuElement.getBoundingClientRect(); 335 if (parentRect.top > actionMenuRect.top || parentRect.bottom < actionMenuRect.bottom) { 336 actionMenuElement.scrollIntoView(parentRect.top > actionMenuRect.top); 337 } 338 } 339 return true; 340 } else if (eventCode === "→") { 341 const currentElement = window.siyuan.menus.menu.element.querySelector(".b3-menu__item--current"); 342 if (!currentElement) { 343 return true; 344 } 345 const subMenuElement = currentElement.querySelector(".b3-menu__submenu") as HTMLElement; 346 if (!subMenuElement) { 347 return true; 348 } 349 currentElement.classList.remove("b3-menu__item--current"); 350 currentElement.classList.add("b3-menu__item--show"); 351 352 const actionMenuElement = getActionMenu(subMenuElement.firstElementChild.firstElementChild, true); 353 if (actionMenuElement) { 354 actionMenuElement.classList.add("b3-menu__item--current"); 355 } 356 window.siyuan.menus.menu.showSubMenu(subMenuElement); 357 return true; 358 } else if (eventCode === "←") { 359 const currentElement = window.siyuan.menus.menu.element.querySelector(".b3-menu__submenu .b3-menu__item--current"); 360 if (!currentElement) { 361 return true; 362 } 363 const parentItemElement = hasClosestByClassName(currentElement, "b3-menu__item--show"); 364 if (parentItemElement) { 365 parentItemElement.classList.remove("b3-menu__item--show"); 366 parentItemElement.classList.add("b3-menu__item--current"); 367 currentElement.classList.remove("b3-menu__item--current"); 368 } 369 return true; 370 } else if (eventCode === "↩") { 371 const currentElement = window.siyuan.menus.menu.element.querySelector(".b3-menu__item--current"); 372 if (!currentElement) { 373 return false; 374 } else { 375 const subMenuElement = currentElement.querySelector(".b3-menu__submenu") as HTMLElement; 376 if (subMenuElement) { 377 currentElement.classList.remove("b3-menu__item--current"); 378 currentElement.classList.add("b3-menu__item--show"); 379 const actionMenuElement = getActionMenu(subMenuElement.firstElementChild.firstElementChild, true); 380 if (actionMenuElement) { 381 actionMenuElement.classList.add("b3-menu__item--current"); 382 } 383 window.siyuan.menus.menu.showSubMenu(subMenuElement); 384 return true; 385 } 386 const textElement = currentElement.querySelector(".b3-text-field") as HTMLInputElement; 387 const checkElement = currentElement.querySelector(".b3-switch") as HTMLInputElement; 388 if (textElement) { 389 textElement.focus(); 390 return true; 391 } else if (checkElement) { 392 checkElement.click(); 393 } else { 394 currentElement.dispatchEvent(new CustomEvent(getEventName())); 395 } 396 if (window.siyuan.menus.menu.element.contains(currentElement)) { 397 // 块标上 AI 会使用新的 menu,不能移除 398 window.siyuan.menus.menu.remove(); 399 } 400 } 401 return true; 402 } 403}; 404 405export class subMenu { 406 public menus: IMenu[]; 407 408 constructor() { 409 this.menus = []; 410 } 411 412 addSeparator(index?: number, id?: string) { 413 if (typeof index === "number") { 414 this.menus.splice(index, 0, {type: "separator", id}); 415 } else { 416 this.menus.push({type: "separator", id}); 417 } 418 } 419 420 addItem(menu: IMenu) { 421 if (typeof menu.index === "number") { 422 this.menus.splice(menu.index, 0, menu); 423 } else { 424 this.menus.push(menu); 425 } 426 } 427}