Notes/apps/client/src/widgets/tab_row.ts

961 lines
33 KiB
TypeScript
Raw Normal View History

import Draggabilly, { type MoveVector } from "draggabilly";
2024-10-15 14:51:26 +08:00
import { t } from "../services/i18n.js";
2020-01-14 21:52:18 +01:00
import BasicWidget from "./basic_widget.js";
2022-08-05 16:44:26 +02:00
import contextMenu from "../menus/context_menu.js";
2020-01-19 21:24:14 +01:00
import utils from "../services/utils.js";
2020-01-20 22:35:52 +01:00
import keyboardActionService from "../services/keyboard_actions.js";
import appContext, { type CommandNames, type CommandListenerData, type EventData } from "../components/app_context.js";
2021-04-16 23:01:56 +02:00
import froca from "../services/froca.js";
2021-08-25 22:49:24 +02:00
import attributeService from "../services/attributes.js";
2025-01-09 20:20:06 +02:00
import type NoteContext from "../components/note_context.js";
2020-01-12 19:05:09 +01:00
2025-02-08 13:24:55 +02:00
const isDesktop = utils.isDesktop();
2025-04-14 17:20:35 +08:00
const TAB_CONTAINER_MIN_WIDTH = 100;
2020-02-01 19:25:37 +01:00
const TAB_CONTAINER_MAX_WIDTH = 240;
2023-10-19 23:54:36 +02:00
const TAB_CONTAINER_LEFT_PADDING = 5;
2025-04-14 17:20:35 +08:00
const SCROLL_BUTTON_WIDTH = 36;
const NEW_TAB_WIDTH = 36;
2025-03-02 20:47:57 +01:00
const MIN_FILLER_WIDTH = isDesktop ? 50 : 15;
2021-05-23 21:24:22 +02:00
const MARGIN_WIDTH = 5;
2019-04-30 22:31:12 +02:00
2019-05-11 19:44:58 +02:00
const TAB_SIZE_SMALL = 84;
const TAB_SIZE_SMALLER = 60;
const TAB_SIZE_MINI = 48;
2019-04-30 22:31:12 +02:00
2020-01-12 20:15:05 +01:00
const TAB_TPL = `
2019-05-11 19:44:58 +02:00
<div class="note-tab">
<div class="note-tab-wrapper">
2021-10-12 22:36:22 +02:00
<div class="note-tab-drag-handle"></div>
<div class="note-tab-icon"></div>
2019-05-11 19:44:58 +02:00
<div class="note-tab-title"></div>
<div class="note-tab-close bx bx-x" title="${t("tab_row.close_tab")}"></div>
2019-05-11 19:44:58 +02:00
</div>
2019-05-12 10:11:41 +02:00
</div>`;
2019-05-11 19:44:58 +02:00
2025-04-14 17:20:35 +08:00
const CONTAINER_ANCHOR_TPL = `<div class="tab-row-container-anchor"></div>`;
2025-01-09 18:07:02 +02:00
const NEW_TAB_BUTTON_TPL = `<div class="note-new-tab" data-trigger-command="openNewTab" title="${t("tab_row.add_new_tab")}">+</div>`;
2021-05-23 21:24:22 +02:00
const FILLER_TPL = `<div class="tab-row-filler"></div>`;
2019-05-12 10:59:53 +02:00
2020-01-12 20:15:05 +01:00
const TAB_ROW_TPL = `
2021-06-13 22:55:31 +02:00
<div class="tab-row-widget">
2020-01-12 20:15:05 +01:00
<style>
2021-06-13 22:55:31 +02:00
.tab-row-widget {
2025-04-14 17:20:35 +08:00
display:flex;
2020-01-12 20:15:05 +01:00
box-sizing: border-box;
position: relative;
width: 100%;
background: var(--main-background-color);
2025-02-07 22:03:22 +02:00
user-select: none;
2020-01-12 20:15:05 +01:00
}
.tab-row-widget.full-width {
background: var(--launcher-pane-background-color);
}
2021-06-13 22:55:31 +02:00
.tab-row-widget * {
2020-01-12 20:15:05 +01:00
box-sizing: inherit;
font: inherit;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .tab-row-widget-container {
2020-08-27 22:03:56 +02:00
box-sizing: border-box;
2020-01-12 20:15:05 +01:00
position: relative;
height: 100%;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab {
2020-01-12 20:15:05 +01:00
position: absolute;
left: 0;
width: 240px;
border: 0;
margin: 0;
z-index: 1;
pointer-events: none;
}
2020-01-12 20:15:05 +01:00
.note-new-tab {
2025-04-14 17:20:35 +08:00
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 ${NEW_TAB_WIDTH}px;
height: ${NEW_TAB_WIDTH}px;
2021-06-02 21:23:40 +02:00
padding: 1px;
2020-01-12 20:15:05 +01:00
font-size: 24px;
cursor: pointer;
2020-08-27 22:03:56 +02:00
box-sizing: border-box;
2020-01-12 20:15:05 +01:00
}
2020-01-12 20:15:05 +01:00
.note-new-tab:hover {
background-color: var(--accented-background-color);
border-radius: var(--button-border-radius);
2020-01-12 20:15:05 +01:00
}
2020-01-12 20:15:05 +01:00
.tab-row-filler {
2020-08-27 22:03:56 +02:00
box-sizing: border-box;
2020-01-12 20:15:05 +01:00
-webkit-app-region: drag;
2021-06-26 13:35:36 +02:00
height: 100%;
2025-04-14 17:20:35 +08:00
min-width: ${MIN_FILLER_WIDTH}px;
flex-grow: 1;
2020-01-12 20:15:05 +01:00
}
2025-04-14 17:20:35 +08:00
.tab-row-container-anchor{
position: absolute;
left: 0;
width: 0px;
height: 36px;
border: 0;
margin: 0;
z-index: 1;
cursor: pointer;
box-sizing: border-box;
}
2025-02-08 13:24:55 +02:00
body.mobile .tab-row-filler {
display: none;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab[active] {
2020-01-12 20:15:05 +01:00
z-index: 5;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab,
.tab-row-widget .note-tab * {
2020-01-12 20:15:05 +01:00
cursor: default;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab.note-tab-was-just-added {
2020-01-12 20:15:05 +01:00
top: 10px;
animation: note-tab-was-just-added 120ms forwards ease-in-out;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab .note-tab-wrapper {
2020-01-12 20:15:05 +01:00
position: absolute;
display: flex;
2023-01-16 22:28:55 +01:00
align-items: center;
2020-01-12 20:15:05 +01:00
top: 0;
bottom: 0;
left: 0;
right: 0;
2021-06-01 23:19:49 +02:00
height: 36px;
2021-07-04 15:49:52 +02:00
padding: 7px 5px 7px 11px;
2021-05-23 21:24:22 +02:00
border-radius: 8px;
2020-01-12 20:15:05 +01:00
overflow: hidden;
pointer-events: all;
2021-06-02 21:23:40 +02:00
color: var(--inactive-tab-text-color);
--tab-background-color: var(--workspace-tab-background-color);
background-color: var(--tab-background-color, var(--inactive-tab-background-color));
2020-01-12 20:15:05 +01:00
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab[active] .note-tab-wrapper {
2020-01-12 20:15:05 +01:00
font-weight: bold;
2021-06-02 21:23:40 +02:00
color: var(--active-tab-text-color);
background-color : var(--tab-background-color, var(--active-tab-background-color));
2020-01-12 20:15:05 +01:00
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab[is-mini] .note-tab-wrapper {
2020-01-12 20:15:05 +01:00
padding-left: 2px;
padding-right: 2px;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab .note-tab-title {
2020-01-12 20:15:05 +01:00
flex: 1;
vertical-align: top;
overflow: hidden;
white-space: nowrap;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab .note-tab-icon {
position: relative;
top: -1px;
padding-right: 3px;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab[is-small] .note-tab-title {
2020-01-12 20:15:05 +01:00
margin-left: 0;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab .note-tab-drag-handle {
2020-01-12 20:15:05 +01:00
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
z-index: 50;
2020-01-12 20:15:05 +01:00
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab .note-tab-close {
2023-01-16 22:28:55 +01:00
flex: 0 0 22px;
2020-01-12 20:15:05 +01:00
border-radius: 50%;
z-index: 100;
2021-07-04 15:49:52 +02:00
width: 22px;
2023-01-16 22:28:55 +01:00
height: 22px;
cursor: pointer;
2023-01-16 22:28:55 +01:00
text-align: center;
2020-01-12 20:15:05 +01:00
}
2025-04-14 17:20:35 +08:00
.tab-scroll-button-left, .tab-scroll-button-right {
display: none;
flex: 0 0 ${SCROLL_BUTTON_WIDTH}px;
height: ${SCROLL_BUTTON_WIDTH}px;
padding: 1px 1px 1px 1px;
align-items: center;
justify-content: center;
cursor: pointer;
}
.tab-scroll-button-left {
color: var(--active-tab-text-color);
box-shadow: inset -1px 0 0 0 var(--main-border-color);
}
2025-05-28 20:42:21 +03:00
2025-04-14 17:20:35 +08:00
.tab-scroll-button-right {
color: var(--active-tab-text-color);
box-shadow: inset 1px 0 0 0 var(--main-border-color);
}
.tab-scroll-button-left.disabled,
.tab-scroll-button-right.disabled {
color: var(--inactive-tab-text-color);
box-shadow: none;
pointer-events: none;
}
.tab-scroll-button-left:hover,
.tab-scroll-button-right:hover {
background-color: var(--tab-background-color, var(--inactive-tab-hover-background-color));
}
.tab-row-widget .note-tab:hover .note-tab-wrapper {
background-color: var(--tab-background-color, var(--inactive-tab-hover-background-color));
}
.tab-row-widget .note-tab[active]:hover .note-tab-wrapper {
background-color: var(--tab-background-color, var(--active-tab-hover-background-color));
}
2025-02-08 13:24:55 +02:00
body.desktop .tab-row-widget .note-tab .note-tab-close:hover {
2020-01-12 20:15:05 +01:00
background-color: var(--hover-item-background-color);
color: var(--hover-item-text-color);
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab[is-smaller] .note-tab-close {
2020-01-12 20:15:05 +01:00
margin-left: auto;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab[is-mini]:not([active]) .note-tab-close {
2020-01-12 20:15:05 +01:00
display: none;
}
2021-06-13 22:55:31 +02:00
.tab-row-widget .note-tab[is-mini][active] .note-tab-close {
2020-01-12 20:15:05 +01:00
margin-left: auto;
margin-right: auto;
}
@-moz-keyframes note-tab-was-just-added {
to {
top: 0;
}
}
@-webkit-keyframes note-tab-was-just-added {
to {
top: 0;
}
}
@-o-keyframes note-tab-was-just-added {
to {
top: 0;
}
}
@keyframes note-tab-was-just-added {
to {
top: 0;
}
}
2021-06-13 22:55:31 +02:00
.tab-row-widget.tab-row-widget-is-sorting .note-tab:not(.note-tab-is-dragging),
.tab-row-widget:not(.tab-row-widget-is-sorting) .note-tab.note-tab-was-just-dragged {
2020-01-12 20:15:05 +01:00
transition: transform 120ms ease-in-out;
}
2025-04-14 17:20:35 +08:00
.tab-row-widget-wrapper {
display: flex;
box-sizing: border-box;
width: 100%;
height: 100%;
}
2025-05-28 20:42:21 +03:00
2025-04-14 17:20:35 +08:00
.tab-row-widget-scrolling-container {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none; /* Firefox */
}
/* Chrome/Safari */
.tab-row-widget-scrolling-container::-webkit-scrollbar {
2025-05-28 20:42:21 +03:00
display: none;
2025-04-14 17:20:35 +08:00
}
2025-05-28 20:42:21 +03:00
2020-01-12 20:15:05 +01:00
</style>
2025-04-14 17:20:35 +08:00
<div class="tab-scroll-button-left bx bx-chevron-left"></div>
<div class="tab-row-widget-scrolling-container">
<div class="tab-row-widget-container"></div>
</div>
<div class="tab-scroll-button-right bx bx-chevron-right"></div>
2020-01-12 19:05:09 +01:00
</div>`;
export default class TabRowWidget extends BasicWidget {
2025-01-09 20:20:06 +02:00
private isDragging?: boolean;
private showNoteIcons?: boolean;
private draggabillies!: Draggabilly[];
private draggabillyDragging?: Draggabilly | null;
private $style!: JQuery<HTMLElement>;
2025-04-14 17:20:35 +08:00
private $tabScrollingContainer!: JQuery<HTMLElement>;
private $tabContainer!: JQuery<HTMLElement>;
private $scrollButtonLeft!: JQuery<HTMLElement>;
private $scrollButtonRight!: JQuery<HTMLElement>;
private $containerAnchor!: JQuery<HTMLElement>;
2025-01-09 20:20:06 +02:00
private $filler!: JQuery<HTMLElement>;
private $newTab!: JQuery<HTMLElement>;
2025-04-14 17:20:35 +08:00
private updateScrollTimeout: ReturnType<typeof setTimeout> | undefined;
private newTabOuterWidth: number = 0;
private scrollButtonsOuterWidth: number = 0;
2025-01-09 20:20:06 +02:00
2020-01-12 20:15:05 +01:00
doRender() {
2020-01-15 22:11:30 +01:00
this.$widget = $(TAB_ROW_TPL);
2025-04-14 17:20:35 +08:00
this.$tabScrollingContainer = this.$widget.children(".tab-row-widget-scrolling-container");
this.$tabContainer = this.$widget.find(".tab-row-widget-container");
this.$scrollButtonLeft = this.$widget.children(".tab-scroll-button-left");
this.$scrollButtonRight = this.$widget.children(".tab-scroll-button-right");
2025-01-09 18:07:02 +02:00
const documentStyle = window.getComputedStyle(document.documentElement);
2025-01-09 18:07:02 +02:00
this.showNoteIcons = documentStyle.getPropertyValue("--tab-note-icons") === "true";
2020-01-12 19:05:09 +01:00
this.draggabillies = [];
2019-04-30 22:31:12 +02:00
2020-01-15 22:11:30 +01:00
this.setupStyle();
2019-05-11 19:44:58 +02:00
this.setupEvents();
2025-04-14 17:20:35 +08:00
this.setupContainerAnchor();
2019-05-11 19:44:58 +02:00
this.setupDraggabilly();
2019-05-12 10:59:53 +02:00
this.setupNewButton();
2019-11-17 10:22:26 +01:00
this.setupFiller();
this.layoutTabs();
2019-05-11 19:44:58 +02:00
this.setVisibility();
2025-04-14 17:20:35 +08:00
this.setupScrollEvents();
2025-01-09 18:07:02 +02:00
this.$widget.on("contextmenu", ".note-tab", (e) => {
2020-01-12 19:05:09 +01:00
e.preventDefault();
2025-01-09 18:07:02 +02:00
const ntxId = $(e.target).closest(".note-tab").attr("data-ntx-id");
2020-01-12 19:05:09 +01:00
contextMenu.show<CommandNames>({
2020-02-29 13:03:05 +01:00
x: e.pageX,
y: e.pageY,
items: [
2025-01-09 18:07:02 +02:00
{ title: t("tab_row.close"), command: "closeTab", uiIcon: "bx bx-x" },
{ title: t("tab_row.close_other_tabs"), command: "closeOtherTabs", uiIcon: "bx bx-empty", enabled: appContext.tabManager.noteContexts.length !== 1 },
2025-01-09 20:20:06 +02:00
{ title: t("tab_row.close_right_tabs"), command: "closeRightTabs", uiIcon: "bx bx-empty", enabled: appContext.tabManager.noteContexts?.at(-1)?.ntxId !== ntxId },
2025-01-09 18:07:02 +02:00
{ title: t("tab_row.close_all_tabs"), command: "closeAllTabs", uiIcon: "bx bx-empty" },
{ title: "----" },
2025-01-09 18:07:02 +02:00
{ title: t("tab_row.reopen_last_tab"), command: "reopenLastTab", uiIcon: "bx bx-undo", enabled: appContext.tabManager.recentlyClosedTabs.length !== 0 },
2025-01-09 18:07:02 +02:00
{ title: "----" },
2025-01-09 18:07:02 +02:00
{ title: t("tab_row.move_tab_to_new_window"), command: "moveTabToNewWindow", uiIcon: "bx bx-window-open" },
{ title: t("tab_row.copy_tab_to_new_window"), command: "copyTabToNewWindow", uiIcon: "bx bx-empty" }
],
2025-01-09 18:07:02 +02:00
selectMenuItemHandler: ({ command }) => {
2025-01-09 20:20:06 +02:00
if (command) {
this.triggerCommand(command, { ntxId });
}
2020-01-12 19:05:09 +01:00
}
});
2020-01-12 20:15:05 +01:00
});
2019-04-30 22:31:12 +02:00
}
2020-01-15 22:11:30 +01:00
setupStyle() {
this.$style = $("<style>");
this.$widget.append(this.$style);
2019-04-30 22:31:12 +02:00
}
2025-04-15 22:09:55 +08:00
scrollTabContainer(direction: number, behavior: ScrollBehavior = "smooth") {
this.$tabScrollingContainer[0].scrollBy({
left: direction,
2025-04-15 22:09:55 +08:00
behavior
2025-04-14 17:20:35 +08:00
});
};
setupScrollEvents() {
let pendingScroll = 0;
let isScrolling = false;
const stepScroll = () => {
if (Math.abs(pendingScroll) > 10) {
const step = Math.round(pendingScroll * 0.2);
pendingScroll -= step;
this.scrollTabContainer(step, "instant");
requestAnimationFrame(stepScroll);
} else {
this.scrollTabContainer(pendingScroll, "instant");
pendingScroll = 0;
isScrolling = false;
}
};
this.$tabScrollingContainer[0].addEventListener('wheel', async (event) => {
if (utils.isCtrlKey(event) || event.altKey || event.shiftKey) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
// Set an upper limit to avoid scrolling too fast
// no lower limit is set because touchpad deltas are usually small
const delta = Math.sign(event.deltaX + event.deltaY) * Math.min(Math.abs(event.deltaX + event.deltaY), TAB_CONTAINER_MIN_WIDTH * 3);
// Check if the device has reduced motion enabled
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.scrollTabContainer(delta, "instant");
} else {
pendingScroll += delta
if (!isScrolling) {
isScrolling = true;
stepScroll();
}
}
2025-04-14 17:20:35 +08:00
});
2025-04-15 22:09:55 +08:00
this.$scrollButtonLeft[0].addEventListener('click', () => this.scrollTabContainer(-200));
this.$scrollButtonRight[0].addEventListener('click', () => this.scrollTabContainer(200));
2025-04-14 17:20:35 +08:00
this.$tabScrollingContainer[0].addEventListener('scroll', () => {
clearTimeout(this.updateScrollTimeout);
this.updateScrollTimeout = setTimeout(() => {
this.updateScrollButtonState();
}, 100);
});
}
updateScrollButtonState() {
const scrollLeft = this.$tabScrollingContainer[0].scrollLeft;
const scrollWidth = this.$tabScrollingContainer[0].scrollWidth;
const clientWidth = this.$tabScrollingContainer[0].clientWidth;
// Detect whether the scrollbar is at the far left or far right.
this.$scrollButtonLeft.toggleClass("disabled", Math.abs(scrollLeft) <= 1);
this.$scrollButtonRight.toggleClass("disabled", Math.abs(scrollLeft + clientWidth - scrollWidth) <= 1);
}
setScrollButtonVisibility(show: boolean = true) {
if (show) {
this.$scrollButtonLeft.css("display", "flex");
this.$scrollButtonRight.css("display", "flex");
clearTimeout(this.updateScrollTimeout);
this.updateScrollTimeout = setTimeout(() => {
this.updateScrollButtonState();
}, 200);
} else {
this.$scrollButtonLeft.css("display", "none");
this.$scrollButtonRight.css("display", "none");
}
}
2019-04-30 22:31:12 +02:00
setupEvents() {
2025-01-09 20:20:06 +02:00
new ResizeObserver((_) => {
2019-05-11 19:44:58 +02:00
this.cleanUpPreviouslyDraggedTabs();
this.layoutTabs();
2025-01-09 20:20:06 +02:00
}).observe(this.$widget[0]);
2019-04-30 22:31:12 +02:00
this.tabEls.forEach((tabEl) => this.setTabCloseEvent(tabEl));
2019-04-30 22:31:12 +02:00
}
setVisibility() {
2020-01-15 22:11:30 +01:00
this.$widget.show();
}
2019-04-30 22:31:12 +02:00
get tabEls() {
2025-01-09 18:07:02 +02:00
return Array.prototype.slice.call(this.$widget.find(".note-tab"));
2019-04-30 22:31:12 +02:00
}
2025-04-14 17:20:35 +08:00
updateOuterWidth() {
if (this.newTabOuterWidth == 0) {
this.newTabOuterWidth = this.$newTab?.outerWidth(true) ?? 0;
}
if (this.scrollButtonsOuterWidth == 0) {
this.scrollButtonsOuterWidth = (this.$scrollButtonLeft?.outerWidth(true) ?? 0) + (this.$scrollButtonRight?.outerWidth(true) ?? 0);
}
2019-04-30 22:31:12 +02:00
}
2025-04-14 17:20:35 +08:00
2020-02-01 19:25:37 +01:00
get tabWidths() {
2019-05-11 19:44:58 +02:00
const numberOfTabs = this.tabEls.length;
2025-04-14 17:20:35 +08:00
// this.$newTab may include margin, and using NEW_TAB_WIDTH could cause tabsContainerWidth to be slightly larger,
// resulting in misaligned scrollbars/buttons. Therefore, use outerwidth.
this.updateOuterWidth();
let tabsContainerWidth = Math.floor(
(this.$widget.width() ?? 0) - this.newTabOuterWidth - MIN_FILLER_WIDTH
);
2025-04-14 17:20:35 +08:00
// Check whether the scroll buttons need to be displayed.
if ((TAB_CONTAINER_MIN_WIDTH + MARGIN_WIDTH) * numberOfTabs > tabsContainerWidth) {
tabsContainerWidth -= this.scrollButtonsOuterWidth;
this.setScrollButtonVisibility(true);
} else {
this.setScrollButtonVisibility(false);
}
const marginWidth = (numberOfTabs - 1) * MARGIN_WIDTH + TAB_CONTAINER_LEFT_PADDING;
2021-05-23 21:24:22 +02:00
const targetWidth = (tabsContainerWidth - marginWidth) / numberOfTabs;
2020-02-01 19:25:37 +01:00
const clampedTargetWidth = Math.max(TAB_CONTAINER_MIN_WIDTH, Math.min(TAB_CONTAINER_MAX_WIDTH, targetWidth));
2019-05-11 19:44:58 +02:00
const flooredClampedTargetWidth = Math.floor(clampedTargetWidth);
2021-05-23 21:24:22 +02:00
const totalTabsWidthUsingTarget = flooredClampedTargetWidth * numberOfTabs + marginWidth;
2020-02-01 19:25:37 +01:00
const totalExtraWidthDueToFlooring = tabsContainerWidth - totalTabsWidthUsingTarget;
2019-05-11 19:44:58 +02:00
2025-05-28 20:42:21 +03:00
const widths: number[] = [];
2019-05-11 19:44:58 +02:00
let extraWidthRemaining = totalExtraWidthDueToFlooring;
2019-11-17 10:22:26 +01:00
2019-05-11 19:44:58 +02:00
for (let i = 0; i < numberOfTabs; i += 1) {
2020-02-01 19:25:37 +01:00
const extraWidth = flooredClampedTargetWidth < TAB_CONTAINER_MAX_WIDTH && extraWidthRemaining > 0 ? 1 : 0;
2021-05-23 21:24:22 +02:00
2019-05-11 19:44:58 +02:00
widths.push(flooredClampedTargetWidth + extraWidth);
2021-05-23 21:24:22 +02:00
if (extraWidthRemaining > 0) {
extraWidthRemaining -= 1;
}
2019-05-11 19:44:58 +02:00
}
2019-04-30 22:31:12 +02:00
2019-05-12 10:11:41 +02:00
return widths;
2019-04-30 22:31:12 +02:00
}
2019-05-12 10:59:53 +02:00
getTabPositions() {
2025-01-09 20:20:06 +02:00
const tabPositions: number[] = [];
2019-05-11 19:44:58 +02:00
2023-10-19 23:54:36 +02:00
let position = TAB_CONTAINER_LEFT_PADDING;
2025-01-09 18:07:02 +02:00
this.tabWidths.forEach((width) => {
2019-05-12 10:59:53 +02:00
tabPositions.push(position);
2021-05-23 21:24:22 +02:00
position += width + MARGIN_WIDTH;
2019-05-11 19:44:58 +02:00
});
2019-04-30 22:31:12 +02:00
2023-06-30 11:18:34 +02:00
position -= MARGIN_WIDTH; // the last margin should not be applied
2021-05-23 21:24:22 +02:00
2025-04-14 17:20:35 +08:00
const anchorPosition = position;
2019-04-30 22:31:12 +02:00
2025-04-14 17:20:35 +08:00
return { tabPositions, anchorPosition };
2019-04-30 22:31:12 +02:00
}
layoutTabs() {
2020-02-01 19:25:37 +01:00
const tabContainerWidths = this.tabWidths;
2019-05-11 19:44:58 +02:00
this.tabEls.forEach((tabEl, i) => {
2020-02-01 19:25:37 +01:00
const width = tabContainerWidths[i];
2019-05-11 19:44:58 +02:00
tabEl.style.width = `${width}px`;
2025-01-09 18:07:02 +02:00
tabEl.removeAttribute("is-small");
tabEl.removeAttribute("is-smaller");
tabEl.removeAttribute("is-mini");
2019-05-11 19:44:58 +02:00
2025-01-09 18:07:02 +02:00
if (width < TAB_SIZE_SMALL) tabEl.setAttribute("is-small", "");
if (width < TAB_SIZE_SMALLER) tabEl.setAttribute("is-smaller", "");
if (width < TAB_SIZE_MINI) tabEl.setAttribute("is-mini", "");
2019-05-11 19:44:58 +02:00
});
2025-01-09 18:07:02 +02:00
let styleHTML = "";
2019-05-12 10:59:53 +02:00
2025-04-14 17:20:35 +08:00
const { tabPositions, anchorPosition } = this.getTabPositions();
2019-05-12 10:59:53 +02:00
tabPositions.forEach((position, i) => {
2025-01-09 18:07:02 +02:00
styleHTML += `.note-tab:nth-child(${i + 1}) { transform: translate3d(${position}px, 0, 0)} `;
2019-05-11 19:44:58 +02:00
});
2025-04-14 17:20:35 +08:00
styleHTML += `.tab-row-container-anchor { transform: translate3d(${anchorPosition}px, 0, 0) } `;
styleHTML += `.tab-row-widget-container {width: ${anchorPosition}px}`;
2020-01-15 22:11:30 +01:00
this.$style.html(styleHTML);
2019-04-30 22:31:12 +02:00
}
2025-01-09 20:20:06 +02:00
addTab(ntxId: string) {
2025-01-09 18:07:02 +02:00
const $tab = $(TAB_TPL).attr("data-ntx-id", ntxId);
2019-04-30 22:31:12 +02:00
2020-01-20 22:35:52 +01:00
keyboardActionService.updateDisplayedShortcuts($tab);
2019-04-30 22:31:12 +02:00
2025-01-09 18:07:02 +02:00
$tab.addClass("note-tab-was-just-added");
2020-01-20 22:35:52 +01:00
2025-01-09 18:07:02 +02:00
setTimeout(() => $tab.removeClass("note-tab-was-just-added"), 500);
2025-04-14 17:20:35 +08:00
this.$containerAnchor.before($tab);
2019-05-11 19:44:58 +02:00
this.setVisibility();
this.setTabCloseEvent($tab);
2025-01-09 18:07:02 +02:00
this.updateTitle($tab, t("tab_row.new_tab"));
2019-05-11 19:44:58 +02:00
this.cleanUpPreviouslyDraggedTabs();
this.layoutTabs();
this.setupDraggabilly();
2019-04-30 22:31:12 +02:00
}
2025-01-09 20:20:06 +02:00
closeActiveTabCommand({ $el }: CommandListenerData<"closeActiveTab">) {
2025-01-09 18:07:02 +02:00
const ntxId = $el.closest(".note-tab").attr("data-ntx-id");
2025-03-18 18:44:48 +01:00
appContext.tabManager.removeNoteContext(ntxId ?? null);
}
2025-01-09 20:20:06 +02:00
setTabCloseEvent($tab: JQuery<HTMLElement>) {
2025-01-09 18:07:02 +02:00
$tab.on("mousedown", (e) => {
if (e.which === 2) {
2025-03-18 18:44:48 +01:00
appContext.tabManager.removeNoteContext($tab.attr("data-ntx-id") ?? null);
return true; // event has been handled
}
});
$tab.find(".note-tab-close").on("click", (e) => {
this.triggerCommand("closeActiveTab", { $el: $(e.target) });
return true;
});
2019-04-30 22:31:12 +02:00
}
get activeTabEl() {
2025-01-09 18:07:02 +02:00
return this.$widget.find(".note-tab[active]")[0];
2019-04-30 22:31:12 +02:00
}
2021-05-22 17:58:46 +02:00
activeContextChangedEvent() {
2021-05-22 12:35:41 +02:00
let activeNoteContext = appContext.tabManager.getActiveContext();
2020-02-09 21:13:05 +01:00
2021-05-22 12:26:45 +02:00
if (!activeNoteContext) {
return;
}
2021-05-22 12:26:45 +02:00
if (activeNoteContext.mainNtxId) {
activeNoteContext = appContext.tabManager.getNoteContextById(activeNoteContext.mainNtxId);
2021-05-19 23:00:03 +02:00
}
2021-05-22 12:26:45 +02:00
const tabEl = this.getTabById(activeNoteContext.ntxId)[0];
2019-05-11 19:44:58 +02:00
const activeTabEl = this.activeTabEl;
if (activeTabEl === tabEl) return;
2025-01-09 18:07:02 +02:00
if (activeTabEl) activeTabEl.removeAttribute("active");
if (tabEl) tabEl.setAttribute("active", "");
2019-04-30 22:31:12 +02:00
}
2025-01-09 20:20:06 +02:00
newNoteContextCreatedEvent({ noteContext }: EventData<"newNoteContextCreated">) {
if (!noteContext.mainNtxId && noteContext.ntxId) {
2021-05-22 12:26:45 +02:00
this.addTab(noteContext.ntxId);
2021-05-19 23:00:03 +02:00
}
2020-01-20 20:51:22 +01:00
}
2025-01-09 20:20:06 +02:00
removeTab(ntxId: string) {
2021-05-22 12:26:45 +02:00
const tabEl = this.getTabById(ntxId)[0];
2020-01-15 22:27:52 +01:00
if (tabEl) {
2025-01-09 20:20:06 +02:00
tabEl.parentNode?.removeChild(tabEl);
this.cleanUpPreviouslyDraggedTabs();
this.layoutTabs();
this.setupDraggabilly();
this.setVisibility();
}
2019-04-30 22:31:12 +02:00
}
2021-05-24 21:43:24 +02:00
getNtxIdsInOrder() {
2025-01-09 18:07:02 +02:00
return this.tabEls.map((el) => el.getAttribute("data-ntx-id"));
}
2025-01-09 20:20:06 +02:00
updateTitle($tab: JQuery<HTMLElement>, title: string) {
2025-01-09 18:07:02 +02:00
$tab.attr("title", title);
$tab.find(".note-tab-title").text(title);
2019-04-30 22:31:12 +02:00
}
2025-01-09 20:20:06 +02:00
getTabById(ntxId: string | null) {
2021-05-24 21:43:24 +02:00
return this.$widget.find(`[data-ntx-id='${ntxId}']`);
}
2025-01-09 20:20:06 +02:00
getTabId($tab: JQuery<HTMLElement>) {
2025-01-09 18:07:02 +02:00
return $tab.attr("data-ntx-id");
2020-11-24 23:24:05 +01:00
}
noteContextRemovedEvent({ ntxIds }: EventData<"noteContextRemoved">) {
2021-05-22 12:26:45 +02:00
for (const ntxId of ntxIds) {
this.removeTab(ntxId);
2021-05-20 23:13:34 +02:00
}
2020-01-19 21:12:53 +01:00
}
2019-04-30 22:31:12 +02:00
cleanUpPreviouslyDraggedTabs() {
2025-01-09 18:07:02 +02:00
this.tabEls.forEach((tabEl) => tabEl.classList.remove("note-tab-was-just-dragged"));
2019-04-30 22:31:12 +02:00
}
setupDraggabilly() {
2019-05-11 19:44:58 +02:00
const tabEls = this.tabEls;
2025-01-09 18:07:02 +02:00
const { tabPositions } = this.getTabPositions();
2025-04-14 17:20:35 +08:00
let initialScrollLeft = 0;
2019-05-11 19:44:58 +02:00
2025-01-09 20:20:06 +02:00
if (this.isDragging && this.draggabillyDragging) {
2019-05-11 19:44:58 +02:00
this.isDragging = false;
2025-01-09 18:07:02 +02:00
this.$widget.removeClass("tab-row-widget-is-sorting");
2025-01-09 20:20:06 +02:00
// TODO: Some of these don't make sense, might need removal.
2025-01-09 18:07:02 +02:00
this.draggabillyDragging.element.classList.remove("note-tab-is-dragging");
this.draggabillyDragging.element.style.transform = "";
2019-05-11 19:44:58 +02:00
this.draggabillyDragging.dragEnd();
this.draggabillyDragging.isDragging = false;
this.draggabillyDragging.positionDrag = () => { }; // Prevent Draggabilly from updating tabEl.style.transform in later frames
2019-05-11 19:44:58 +02:00
this.draggabillyDragging.destroy();
this.draggabillyDragging = null;
}
2019-04-30 22:31:12 +02:00
2025-01-09 18:07:02 +02:00
this.draggabillies.forEach((d) => d.destroy());
2019-05-11 19:44:58 +02:00
tabEls.forEach((tabEl, originalIndex) => {
const originalTabPositionX = tabPositions[originalIndex];
const draggabilly = new Draggabilly(tabEl, {
2025-01-09 18:07:02 +02:00
axis: "x",
handle: ".note-tab-drag-handle",
2020-02-01 19:25:37 +01:00
containment: this.$tabContainer[0]
2019-05-11 19:44:58 +02:00
});
this.draggabillies.push(draggabilly);
2025-04-15 23:38:08 +08:00
draggabilly.on("staticClick", () => {
appContext.tabManager.activateNoteContext(tabEl.getAttribute("data-ntx-id"));
2019-05-11 19:44:58 +02:00
});
2025-01-09 20:20:06 +02:00
draggabilly.on("dragStart", () => {
2019-05-11 19:44:58 +02:00
this.isDragging = true;
this.draggabillyDragging = draggabilly;
2025-01-09 18:07:02 +02:00
tabEl.classList.add("note-tab-is-dragging");
this.$widget.addClass("tab-row-widget-is-sorting");
2025-04-14 17:20:35 +08:00
initialScrollLeft = this.$tabScrollingContainer?.scrollLeft() ?? 0;
draggabilly.positionDrag = () => { };
2019-05-11 19:44:58 +02:00
});
2025-01-09 20:20:06 +02:00
draggabilly.on("dragEnd", () => {
2019-05-11 19:44:58 +02:00
this.isDragging = false;
2025-04-14 17:20:35 +08:00
const currentScrollLeft = this.$tabScrollingContainer?.scrollLeft() ?? 0;
const scrollDelta = currentScrollLeft - initialScrollLeft;
const translateX = parseFloat(tabEl.style.left) + scrollDelta;
const maxTranslateX = this.$tabContainer[0]?.offsetWidth - tabEl.offsetWidth;
const minTranslateX = 0;
const finalTranslateX = Math.min(maxTranslateX, Math.max(minTranslateX, translateX));
2019-05-11 19:44:58 +02:00
tabEl.style.transform = `translate3d(0, 0, 0)`;
// Animate dragged tab back into its place
2025-01-09 18:07:02 +02:00
requestAnimationFrame((_) => {
tabEl.style.left = "0";
tabEl.style.transform = `translate3d(${finalTranslateX}px, 0, 0)`;
2019-05-11 19:44:58 +02:00
2025-01-09 18:07:02 +02:00
requestAnimationFrame((_) => {
tabEl.classList.remove("note-tab-is-dragging");
this.$widget.removeClass("tab-row-widget-is-sorting");
2019-05-11 19:44:58 +02:00
2025-01-09 18:07:02 +02:00
tabEl.classList.add("note-tab-was-just-dragged");
2019-05-11 19:44:58 +02:00
2025-01-09 18:07:02 +02:00
requestAnimationFrame((_) => {
tabEl.style.transform = "";
2019-05-11 19:44:58 +02:00
this.layoutTabs();
this.setupDraggabilly();
2025-01-09 18:07:02 +02:00
});
});
});
2019-05-11 19:44:58 +02:00
});
2025-04-14 17:20:35 +08:00
draggabilly.on("dragMove", (event: unknown, pointer: PointerEvent, moveVector: MoveVector) => {
2023-06-30 11:18:34 +02:00
// The current index be computed within the event since it can change during the dragMove
2019-05-11 19:44:58 +02:00
const tabEls = this.tabEls;
const currentIndex = tabEls.indexOf(tabEl);
2025-04-14 17:20:35 +08:00
const scorllContainerBounds = this.$tabScrollingContainer[0]?.getBoundingClientRect();
const pointerX = pointer.pageX;
const scrollSpeed = 100; // The increment of each scroll.
// Check if the mouse is near the edge of the container and trigger scrolling.
if (pointerX < scorllContainerBounds.left) {
this.scrollTabContainer(- scrollSpeed);
} else if (pointerX > scorllContainerBounds.right) {
this.scrollTabContainer(scrollSpeed);
}
const currentScrollLeft = this.$tabScrollingContainer?.scrollLeft() ?? 0;
const scrollDelta = currentScrollLeft - initialScrollLeft;
let translateX = moveVector.x + scrollDelta;
// Limit the `translateX` so that `tabEl` cannot exceed the left and right boundaries of the container.
const maxTranslateX = this.$tabContainer[0]?.offsetWidth - tabEl.offsetWidth - originalTabPositionX;
const minTranslateX = - originalTabPositionX;
translateX = Math.min(maxTranslateX, Math.max(minTranslateX, translateX));
tabEl.style.transform = `translate3d(${translateX}px, 0, 0)`;
const currentTabPositionX = originalTabPositionX + translateX;
2019-05-12 10:59:53 +02:00
const destinationIndexTarget = this.closest(currentTabPositionX, tabPositions);
2019-05-11 19:44:58 +02:00
const destinationIndex = Math.max(0, Math.min(tabEls.length, destinationIndexTarget));
if (currentIndex !== destinationIndex) {
this.animateTabMove(tabEl, currentIndex, destinationIndex);
}
if (Math.abs(moveVector.y) > 100) {
2025-01-09 18:07:02 +02:00
this.triggerCommand("moveTabToNewWindow", { ntxId: this.getTabId($(tabEl)) });
}
});
});
2019-04-30 22:31:12 +02:00
}
2025-01-09 20:20:06 +02:00
animateTabMove(tabEl: HTMLElement, originIndex: number, destinationIndex: number) {
2019-05-11 19:44:58 +02:00
if (destinationIndex < originIndex) {
2025-01-09 20:20:06 +02:00
tabEl.parentNode?.insertBefore(tabEl, this.tabEls[destinationIndex]);
2019-05-11 19:44:58 +02:00
} else {
2025-04-14 17:20:35 +08:00
const beforeEl = this.tabEls[destinationIndex + 1] || this.$containerAnchor[0];
2025-01-09 20:20:06 +02:00
tabEl.parentNode?.insertBefore(tabEl, beforeEl);
2019-05-11 19:44:58 +02:00
}
2025-01-09 18:07:02 +02:00
this.triggerEvent("tabReorder", { ntxIdsInOrder: this.getNtxIdsInOrder() });
2019-05-11 19:44:58 +02:00
this.layoutTabs();
2019-04-30 22:31:12 +02:00
}
2019-05-12 10:59:53 +02:00
setupNewButton() {
2020-01-15 22:35:15 +01:00
this.$newTab = $(NEW_TAB_BUTTON_TPL);
2025-04-14 17:20:35 +08:00
this.$widget.append(this.$newTab);
2019-05-12 10:59:53 +02:00
}
2019-11-17 10:22:26 +01:00
setupFiller() {
2020-01-15 22:35:15 +01:00
this.$filler = $(FILLER_TPL);
2019-11-17 10:22:26 +01:00
2025-04-14 17:20:35 +08:00
this.$widget.append(this.$filler);
}
setupContainerAnchor() {
this.$containerAnchor = $(CONTAINER_ANCHOR_TPL);
2025-04-14 17:20:35 +08:00
this.$tabContainer.append(this.$containerAnchor);
2019-11-17 10:22:26 +01:00
}
2025-01-09 20:20:06 +02:00
closest(value: number, array: number[]) {
2019-05-12 10:59:53 +02:00
let closest = Infinity;
let closestIndex = -1;
array.forEach((v, i) => {
if (Math.abs(value - v) < closest) {
closest = Math.abs(value - v);
closestIndex = i;
2019-05-12 10:59:53 +02:00
}
});
return closestIndex;
2025-01-09 18:07:02 +02:00
}
2020-01-19 21:24:14 +01:00
2025-03-03 22:56:24 +01:00
noteSwitchedAndActivatedEvent({ noteContext }: EventData<"noteSwitchedAndActivated">) {
2021-05-22 17:58:46 +02:00
this.activeContextChangedEvent();
2020-02-28 11:46:35 +01:00
2021-05-22 12:26:45 +02:00
this.updateTabById(noteContext.mainNtxId || noteContext.ntxId);
2020-02-28 00:31:12 +01:00
}
2025-01-09 20:20:06 +02:00
noteSwitchedEvent({ noteContext }: EventData<"noteSwitched">) {
2021-05-22 12:26:45 +02:00
this.updateTabById(noteContext.mainNtxId || noteContext.ntxId);
2020-02-28 00:31:12 +01:00
}
noteContextReorderEvent({ oldMainNtxId, newMainNtxId }: EventData<"noteContextReorder">) {
2023-06-01 00:48:37 +08:00
if (!oldMainNtxId || !newMainNtxId) {
// no need to update tab row
2023-05-31 01:53:55 +08:00
return;
2023-06-01 00:48:37 +08:00
}
2023-05-31 01:53:55 +08:00
2023-06-01 00:48:37 +08:00
// update tab id for the new main context
this.getTabById(oldMainNtxId).attr("data-ntx-id", newMainNtxId);
2023-10-19 23:54:36 +02:00
this.updateTabById(newMainNtxId);
2023-05-31 01:53:55 +08:00
}
2025-03-03 22:56:24 +01:00
contextsReopenedEvent({ mainNtxId, tabPosition }: EventData<"contextsReopened">) {
if (!mainNtxId || !tabPosition) {
// no tab reopened
return;
}
const tabEl = this.getTabById(mainNtxId)[0];
2025-01-09 20:20:06 +02:00
tabEl.parentNode?.insertBefore(tabEl, this.tabEls[tabPosition]);
}
2025-01-09 20:20:06 +02:00
updateTabById(ntxId: string | null) {
2021-05-22 12:26:45 +02:00
const $tab = this.getTabById(ntxId);
2025-04-14 17:20:35 +08:00
$tab[0].scrollIntoView({
behavior: 'smooth'
});
const noteContext = appContext.tabManager.getNoteContextById(ntxId);
2020-01-19 21:24:14 +01:00
this.updateTab($tab, noteContext);
2020-02-01 19:25:37 +01:00
}
2025-01-09 20:20:06 +02:00
async updateTab($tab: JQuery<HTMLElement>, noteContext: NoteContext) {
2020-05-12 12:45:32 +02:00
if (!$tab.length) {
2020-01-19 21:24:14 +01:00
return;
}
2025-01-09 18:07:02 +02:00
for (const clazz of Array.from($tab[0].classList)) {
// create copy to safely iterate over while removing classes
if (clazz !== "note-tab") {
2020-01-19 21:24:14 +01:00
$tab.removeClass(clazz);
}
}
let noteIcon = "";
2021-05-22 12:26:45 +02:00
if (noteContext) {
const hoistedNote = froca.getNoteFromCache(noteContext.hoistedNoteId);
2020-11-24 23:24:05 +01:00
if (hoistedNote) {
2025-01-09 18:07:02 +02:00
$tab.find(".note-tab-wrapper").css("--workspace-tab-background-color", hoistedNote.getWorkspaceTabBackgroundColor());
if (!this.showNoteIcons) {
noteIcon = hoistedNote.getWorkspaceIconClass();
}
} else {
2025-01-09 18:07:02 +02:00
$tab.find(".note-tab-wrapper").removeAttr("style");
2020-11-24 23:24:05 +01:00
}
}
2025-01-09 18:07:02 +02:00
const { note } = noteContext;
2020-05-12 12:45:32 +02:00
if (!note) {
2025-01-09 18:07:02 +02:00
this.updateTitle($tab, t("tab_row.new_tab"));
return;
2025-01-09 18:07:02 +02:00
}
2020-05-12 12:45:32 +02:00
const title = await noteContext.getNavigationTitle();
2025-01-09 20:20:06 +02:00
if (title) {
this.updateTitle($tab, title);
}
2020-05-12 12:45:32 +02:00
$tab.addClass(note.getCssClass());
2020-01-19 21:24:14 +01:00
$tab.addClass(utils.getNoteTypeClass(note.type));
$tab.addClass(utils.getMimeTypeClass(note.mime));
if (this.showNoteIcons) {
noteIcon = note.getIcon();
}
if (noteIcon) {
2025-01-09 18:07:02 +02:00
$tab.find(".note-tab-icon").removeClass().addClass("note-tab-icon").addClass(noteIcon);
}
2020-01-19 21:24:14 +01:00
}
2020-01-24 15:44:24 +01:00
2025-01-09 20:20:06 +02:00
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
2021-05-22 12:26:45 +02:00
for (const noteContext of appContext.tabManager.noteContexts) {
if (!noteContext.noteId) {
2021-02-07 20:55:49 +01:00
continue;
}
2025-01-09 18:07:02 +02:00
if (
loadResults.isNoteReloaded(noteContext.noteId) ||
loadResults
.getAttributeRows()
2025-01-09 20:20:06 +02:00
.find((attr) => ["workspace", "workspaceIconClass", "workspaceTabBackgroundColor"].includes(attr.name || "") && attributeService.isAffecting(attr, noteContext.note))
2021-02-07 20:55:49 +01:00
) {
2021-05-22 12:26:45 +02:00
const $tab = this.getTabById(noteContext.ntxId);
2020-02-01 19:25:37 +01:00
this.updateTab($tab, noteContext);
2020-02-01 19:25:37 +01:00
}
}
}
2021-04-16 22:57:37 +02:00
frocaReloadedEvent() {
2021-05-22 12:26:45 +02:00
for (const noteContext of appContext.tabManager.noteContexts) {
const $tab = this.getTabById(noteContext.ntxId);
this.updateTab($tab, noteContext);
}
2020-01-24 15:44:24 +01:00
}
2025-01-09 20:20:06 +02:00
hoistedNoteChangedEvent({ ntxId }: EventData<"hoistedNoteChanged">) {
2021-05-22 12:26:45 +02:00
const $tab = this.getTabById(ntxId);
if ($tab && ntxId) {
2021-05-22 12:26:45 +02:00
const noteContext = appContext.tabManager.getNoteContextById(ntxId);
this.updateTab($tab, noteContext);
}
}
2020-05-12 12:45:32 +02:00
}