A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang. (PERSONAL FORK)
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 || " "}</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}