mirror of
https://github.com/zadam/trilium.git
synced 2025-12-04 22:44:25 +01:00
Compare commits
45 Commits
ce6c39e558
...
36f4e53368
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36f4e53368 | ||
|
|
b9e257a39d | ||
|
|
e7eaa5fd58 | ||
|
|
c9aa992e73 | ||
|
|
f325930f68 | ||
|
|
1346ffb77e | ||
|
|
3378746530 | ||
|
|
ce2d94f04e | ||
|
|
b3c2a1e6c5 | ||
|
|
dbf63787da | ||
|
|
88a7ebef69 | ||
|
|
a716151dd9 | ||
|
|
7462f1b7a5 | ||
|
|
ec76b9dc5c | ||
|
|
79cd96ade9 | ||
|
|
a5b84406be | ||
|
|
8c1a04c4b2 | ||
|
|
ee81037173 | ||
|
|
453349be26 | ||
|
|
81a9e06b23 | ||
|
|
7d8af0f252 | ||
|
|
a68cd7526b | ||
|
|
470ca3b6dc | ||
|
|
e8bae61afc | ||
|
|
c1f663a200 | ||
|
|
22b2e21df0 | ||
|
|
5f19710791 | ||
|
|
d3f3ff4eab | ||
|
|
5af7425cae | ||
|
|
fe10c9f8c8 | ||
|
|
cd2a085d00 | ||
|
|
3c61626370 | ||
|
|
351fe5848f | ||
|
|
ca7bbefbdc | ||
|
|
7094f71e32 | ||
|
|
88b5e9db87 | ||
|
|
53a8f6b4c0 | ||
|
|
9ae1a55896 | ||
|
|
a1c0314334 | ||
|
|
3ecdcd9ea0 | ||
|
|
4d1a91baa6 | ||
|
|
1898efa282 | ||
|
|
648ab4d736 | ||
|
|
407cac588a | ||
|
|
210dcfb989 |
@ -487,7 +487,7 @@ type EventMappings = {
|
||||
relationMapResetPanZoom: { ntxId: string | null | undefined };
|
||||
relationMapResetZoomIn: { ntxId: string | null | undefined };
|
||||
relationMapResetZoomOut: { ntxId: string | null | undefined };
|
||||
activeNoteChanged: {};
|
||||
activeNoteChanged: {ntxId: string | null | undefined};
|
||||
showAddLinkDialog: AddLinkOpts;
|
||||
showIncludeDialog: IncludeNoteOpts;
|
||||
openBulkActionsDialog: {
|
||||
|
||||
@ -165,7 +165,7 @@ export default class TabManager extends Component {
|
||||
const activeNoteContext = this.getActiveContext();
|
||||
this.updateDocumentTitle(activeNoteContext);
|
||||
|
||||
this.triggerEvent("activeNoteChanged", {}); // trigger this even in on popstate event
|
||||
this.triggerEvent("activeNoteChanged", {ntxId:activeNoteContext?.ntxId}); // trigger this even in on popstate event
|
||||
}
|
||||
|
||||
calculateHash(): string {
|
||||
|
||||
@ -32,6 +32,7 @@ import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
span.keyboard-shortcut,
|
||||
kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import clsx from "clsx";
|
||||
import {readCssVar} from "../utils/css-var";
|
||||
import Color, { ColorInstance } from "color";
|
||||
|
||||
const registeredClasses = new Set<string>();
|
||||
const colorsWithHue = new Set<string>();
|
||||
|
||||
// Read the color lightness limits defined in the theme as CSS variables
|
||||
|
||||
@ -26,19 +28,23 @@ function createClassForColor(colorString: string | null) {
|
||||
if (!registeredClasses.has(className)) {
|
||||
const adjustedColor = adjustColorLightness(color, lightThemeColorMaxLightness!,
|
||||
darkThemeColorMinLightness!);
|
||||
const hue = getHue(color);
|
||||
|
||||
$("head").append(`<style>
|
||||
.${className}, span.fancytree-active.${className} {
|
||||
--light-theme-custom-color: ${adjustedColor.lightThemeColor};
|
||||
--dark-theme-custom-color: ${adjustedColor.darkThemeColor};
|
||||
--custom-color-hue: ${getHue(color) ?? 'unset'};
|
||||
--custom-color-hue: ${hue ?? 'unset'};
|
||||
}
|
||||
</style>`);
|
||||
|
||||
registeredClasses.add(className);
|
||||
if (hue) {
|
||||
colorsWithHue.add(className);
|
||||
}
|
||||
}
|
||||
|
||||
return className;
|
||||
return clsx(className, colorsWithHue.has(className) && "with-hue");
|
||||
}
|
||||
|
||||
function parseColor(color: string) {
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dropdown-menu:not(.static).calendar-dropdown-menu {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
|
||||
@ -25,7 +25,8 @@
|
||||
--bs-body-font-weight: var(--main-font-weight) !important;
|
||||
--bs-body-color: var(--main-text-color) !important;
|
||||
--bs-body-bg: var(--main-background-color) !important;
|
||||
--ck-mention-list-max-height: 500px;
|
||||
--ck-mention-list-max-height: 500px;
|
||||
--tn-modal-max-height: 90vh;
|
||||
}
|
||||
|
||||
body#trilium-app.motion-disabled *,
|
||||
@ -212,6 +213,16 @@ input::placeholder,
|
||||
background-color: var(--modal-backdrop-color) !important;
|
||||
}
|
||||
|
||||
body.mobile .modal .modal-dialog {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body.mobile .modal .modal-content {
|
||||
border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0;
|
||||
}
|
||||
|
||||
.component {
|
||||
contain: size;
|
||||
}
|
||||
@ -706,11 +717,6 @@ table.promoted-attributes-in-tooltip th {
|
||||
z-index: 32767 !important;
|
||||
}
|
||||
|
||||
.tooltip-trigger {
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bs-tooltip-bottom .tooltip-arrow::before {
|
||||
border-bottom-color: var(--main-border-color) !important;
|
||||
}
|
||||
@ -1006,9 +1012,17 @@ div[data-notify="container"] {
|
||||
font-family: var(--monospace-font-family);
|
||||
}
|
||||
|
||||
svg.ck-icon .note-icon {
|
||||
color: var(--main-text-color);
|
||||
font-size: 20px;
|
||||
svg.ck-icon {
|
||||
&.ck-icon_inherit-color {
|
||||
* {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&.note-icon {
|
||||
color: var(--main-text-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ck-content {
|
||||
@ -1117,10 +1131,6 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.note-detail-empty {
|
||||
margin: 50px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 0.5rem 1rem 0.5rem 1rem !important; /* make modal header padding slightly smaller */
|
||||
}
|
||||
@ -1316,7 +1326,7 @@ body.mobile #context-menu-container.mobile-bottom-menu {
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
top: unset !important;
|
||||
max-height: 90vh;
|
||||
max-height: var(--tn-modal-max-height);
|
||||
overflow: auto !important;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
@ -1379,6 +1389,20 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.right-dropdown-widget .right-dropdown-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-trigger {
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#launcher-pane.horizontal .right-dropdown-widget {
|
||||
width: 53px;
|
||||
}
|
||||
@ -1562,7 +1586,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
inset-inline-end: 0 !important;
|
||||
transform: unset !important;
|
||||
overflow-y: auto;
|
||||
max-height: calc(90vh - var(--dropdown-bottom));
|
||||
max-height: calc(var(--tn-modal-max-height) - var(--dropdown-bottom));
|
||||
}
|
||||
|
||||
#mobile-sidebar-container {
|
||||
|
||||
@ -98,6 +98,7 @@
|
||||
--menu-item-delimiter-color: #ffffff1c;
|
||||
--menu-item-group-header-color: #ffffff91;
|
||||
--menu-section-background-color: #fefefe08;
|
||||
--menu-submenu-mobile-background-color: rgba(0, 0, 0, 0.15);
|
||||
|
||||
--modal-backdrop-color: #000;
|
||||
--modal-shadow-color: rgba(0, 0, 0, .5);
|
||||
@ -300,7 +301,7 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
|
||||
border-color: var(--muted-text-color) !important;
|
||||
}
|
||||
|
||||
.tinted-quick-edit-dialog {
|
||||
.quick-edit-dialog-wrapper.with-hue {
|
||||
--modal-background-color: hsl(var(--custom-color-hue), 8.8%, 11.2%);
|
||||
--modal-border-color: hsl(var(--custom-color-hue), 9.4%, 25.1%);
|
||||
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 13.2%, 20.8%);
|
||||
|
||||
@ -276,7 +276,7 @@
|
||||
--custom-bg-color: hsl(var(--custom-color-hue), 37%, 89%, 1);
|
||||
}
|
||||
|
||||
.tinted-quick-edit-dialog {
|
||||
.quick-edit-dialog-wrapper.with-hue {
|
||||
--modal-background-color: hsl(var(--custom-color-hue), 56%, 96%);
|
||||
--modal-border-color: hsl(var(--custom-color-hue), 33%, 41%);
|
||||
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 40%, 88%);
|
||||
|
||||
@ -62,6 +62,7 @@
|
||||
|
||||
--menu-padding-size: 8px;
|
||||
--menu-item-icon-vert-offset: -2px;
|
||||
--menu-submenu-mobile-background-color: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--more-accented-background-color: var(--card-background-hover-color);
|
||||
|
||||
@ -99,6 +100,14 @@
|
||||
--tree-item-dark-theme-min-color-lightness: 65;
|
||||
}
|
||||
|
||||
body {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.selectable-text {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
body.backdrop-effects-disabled {
|
||||
/* Backdrop effects are disabled, replace the menu background color with the
|
||||
* no-backdrop fallback color */
|
||||
@ -311,6 +320,10 @@ body.mobile #context-menu-cover {
|
||||
|
||||
&.global-menu-cover {
|
||||
bottom: calc(var(--mobile-bottom-offset) + var(--launcher-pane-size));
|
||||
|
||||
@media (min-width: 992px) {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -376,30 +389,16 @@ body.mobile .dropdown-menu {
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
--menu-background-color: rgba(0, 0, 0, 0.15);
|
||||
--menu-background-color: --menu-submenu-mobile-background-color;
|
||||
--bs-dropdown-divider-margin-y: 0.25rem;
|
||||
border-radius: 0;
|
||||
max-height: 0;
|
||||
transition: max-height 100ms ease-in;
|
||||
display: block !important;
|
||||
|
||||
display: block !important;
|
||||
|
||||
&.show {
|
||||
max-height: 1000px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
visibility: visible;
|
||||
margin: 0;
|
||||
height: 3px;
|
||||
border-top: unset;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:after {
|
||||
content: unset;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -124,12 +124,8 @@
|
||||
|
||||
/* The container */
|
||||
|
||||
.note-split.empty-note {
|
||||
--max-content-width: 70%;
|
||||
}
|
||||
|
||||
.note-split.empty-note div.note-detail {
|
||||
margin: 50px auto;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* The search results list */
|
||||
|
||||
@ -345,7 +345,7 @@ body[dir=ltr] #launcher-container {
|
||||
*/
|
||||
|
||||
.calendar-dropdown-widget {
|
||||
padding: 12px;
|
||||
padding: 18px;
|
||||
color: var(--calendar-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@ -1662,7 +1662,7 @@
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "自动检测",
|
||||
"keeps-crashing": "编辑组件时崩溃。请尝试重启 Trilium。如果问题仍然存在,请考虑提交错误报告。"
|
||||
"keeps-crashing": "编辑组件时持续崩溃。请尝试重启 Trilium。如果问题仍然存在,请考虑提交错误报告。"
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "代码块",
|
||||
|
||||
@ -1647,7 +1647,6 @@
|
||||
"read-only-info": {
|
||||
"read-only-note": "Currently viewing a read-only note.",
|
||||
"auto-read-only-note": "This note is shown in a read-only mode for faster loading.",
|
||||
"auto-read-only-learn-more": "Learn more",
|
||||
"edit-note": "Edit note"
|
||||
},
|
||||
"note_types": {
|
||||
|
||||
@ -105,9 +105,11 @@ export default function NoteDetail() {
|
||||
});
|
||||
|
||||
// Automatically focus the editor.
|
||||
useTriliumEvent("activeNoteChanged", () => {
|
||||
// Restore focus to the editor when switching tabs, but only if the note tree is not already focused.
|
||||
if (!document.activeElement?.classList.contains("fancytree-title")) {
|
||||
useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId != ntxId) return;
|
||||
// Restore focus to the editor when switching tabs,
|
||||
// but only if the note tree and the note panel (e.g., note title or note detail) are not focused.
|
||||
if (!document.activeElement?.classList.contains("fancytree-title") && !parentComponent.$widget[0].closest(".note-split")?.contains(document.activeElement)) {
|
||||
parentComponent.triggerCommand("focusOnDetail", { ntxId });
|
||||
}
|
||||
});
|
||||
|
||||
@ -3,34 +3,33 @@ import { t } from "../services/i18n";
|
||||
import { useIsNoteReadOnly, useNoteContext, useTriliumEvent } from "./react/hooks"
|
||||
import Button from "./react/Button";
|
||||
import InfoBar from "./react/InfoBar";
|
||||
import HelpButton from "./react/HelpButton";
|
||||
|
||||
export default function ReadOnlyNoteInfoBar(props: {}) {
|
||||
const {note, noteContext} = useNoteContext();
|
||||
const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext);
|
||||
const { note, noteContext } = useNoteContext();
|
||||
const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext);
|
||||
const isExplicitReadOnly = note?.isLabelTruthy("readOnly");
|
||||
|
||||
return <InfoBar className="read-only-note-info-bar-widget"
|
||||
type={(isExplicitReadOnly ? "subtle" : "prominent")}
|
||||
style={{display: (!isReadOnly) ? "none" : undefined}}>
|
||||
|
||||
<div class="read-only-note-info-bar-widget-content">
|
||||
{(isExplicitReadOnly) ? (
|
||||
<div>{t("read-only-info.read-only-note")}</div>
|
||||
) : (
|
||||
<div>
|
||||
{t("read-only-info.auto-read-only-note")}
|
||||
|
||||
<a class="tn-link"
|
||||
href="https://docs.triliumnotes.org/user-guide/concepts/notes/read-only-notes#automatic-read-only-mode">
|
||||
|
||||
{t("read-only-info.auto-read-only-learn-more")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button text={t("read-only-info.edit-note")}
|
||||
icon="bx-pencil" onClick={() => enableEditing()} />
|
||||
return (
|
||||
<InfoBar
|
||||
className="read-only-note-info-bar-widget"
|
||||
type={(isExplicitReadOnly ? "subtle" : "prominent")}
|
||||
style={{display: (!isReadOnly) ? "none" : undefined}}
|
||||
>
|
||||
<div class="read-only-note-info-bar-widget-content">
|
||||
{(isExplicitReadOnly) ? (
|
||||
<div>{t("read-only-info.read-only-note")}</div>
|
||||
) : (
|
||||
<div>
|
||||
{t("read-only-info.auto-read-only-note")}
|
||||
{" "}
|
||||
<HelpButton helpPage="CoFPLs3dRlXc" />
|
||||
</div>
|
||||
</InfoBar>
|
||||
)}
|
||||
|
||||
}
|
||||
<Button text={t("read-only-info.edit-note")}
|
||||
icon="bx-pencil" onClick={() => enableEditing()} />
|
||||
</div>
|
||||
</InfoBar>
|
||||
);
|
||||
}
|
||||
|
||||
@ -110,7 +110,7 @@ export default class CalendarWidget extends RightDropdownButtonWidget {
|
||||
private weekNotes: string[] = [];
|
||||
|
||||
constructor(title: string = "", icon: string = "") {
|
||||
super(title, icon, DROPDOWN_TPL);
|
||||
super(title, icon, DROPDOWN_TPL, "calendar-dropdown-menu");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
@ -211,8 +211,7 @@ export default class CalendarWidget extends RightDropdownButtonWidget {
|
||||
const $target = $(e.target);
|
||||
|
||||
// Keep dropdown open when clicking on month select button or year selector area
|
||||
if ($target.closest('.btn.dropdown-toggle.select-button').length ||
|
||||
$target.closest('.calendar-year-selector').length) {
|
||||
if ($target.closest('.btn.dropdown-toggle.select-button').length) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -7,9 +7,9 @@ const TPL = /*html*/`
|
||||
<div class="dropdown right-dropdown-widget">
|
||||
<button type="button" data-bs-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false"
|
||||
class="bx right-dropdown-button launcher-button"></button>
|
||||
|
||||
<div class="tooltip-trigger"></div>
|
||||
class="bx right-dropdown-button launcher-button">
|
||||
<div class="tooltip-trigger"></div>
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu"></div>
|
||||
</div>
|
||||
@ -24,14 +24,16 @@ export default class RightDropdownButtonWidget extends BasicWidget {
|
||||
protected dropdown!: Dropdown;
|
||||
protected $tooltip!: JQuery<HTMLElement>;
|
||||
protected tooltip!: Tooltip;
|
||||
private dropdownClass?: string;
|
||||
public $dropdownContent!: JQuery<HTMLElement>;
|
||||
|
||||
constructor(title: string, iconClass: string, dropdownTpl: string) {
|
||||
constructor(title: string, iconClass: string, dropdownTpl: string, dropdownClass?: string) {
|
||||
super();
|
||||
|
||||
this.iconClass = iconClass;
|
||||
this.title = title;
|
||||
this.dropdownTpl = dropdownTpl;
|
||||
this.dropdownClass = dropdownClass;
|
||||
|
||||
this.settings = {
|
||||
titlePlacement: "right"
|
||||
@ -41,15 +43,17 @@ export default class RightDropdownButtonWidget extends BasicWidget {
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$dropdownMenu = this.$widget.find(".dropdown-menu");
|
||||
if (this.dropdownClass) {
|
||||
this.$dropdownMenu.addClass(this.dropdownClass);
|
||||
}
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], {
|
||||
popperConfig: {
|
||||
placement: this.settings.titlePlacement,
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.attr("title", this.title);
|
||||
this.tooltip = Tooltip.getOrCreateInstance(this.$widget[0], {
|
||||
trigger: "hover",
|
||||
this.$tooltip = this.$widget.find(".tooltip-trigger").attr("title", this.title);
|
||||
this.tooltip = new Tooltip(this.$tooltip[0], {
|
||||
placement: handleRightToLeftPlacement(this.settings.titlePlacement),
|
||||
fallbackPlacements: [ handleRightToLeftPlacement(this.settings.titlePlacement) ]
|
||||
});
|
||||
@ -57,7 +61,9 @@ export default class RightDropdownButtonWidget extends BasicWidget {
|
||||
this.$widget
|
||||
.find(".right-dropdown-button")
|
||||
.addClass(this.iconClass)
|
||||
.on("click", () => this.tooltip.hide());
|
||||
.on("click", () => this.tooltip.hide())
|
||||
.on("mouseenter", () => this.tooltip.show())
|
||||
.on("mouseleave", () => this.tooltip.hide());
|
||||
|
||||
this.$widget.on("show.bs.dropdown", async () => {
|
||||
await this.dropdownShown();
|
||||
|
||||
@ -29,7 +29,11 @@ export default class LeftPaneContainer extends FlexContainer<Component> {
|
||||
if (visible) {
|
||||
this.triggerEvent("focusTree", {});
|
||||
} else {
|
||||
this.triggerEvent("focusOnDetail", { ntxId: appContext.tabManager.getActiveContext()?.ntxId });
|
||||
const ntxId = appContext.tabManager.getActiveContext()?.ntxId;
|
||||
const noteContainer = document.querySelector(`.note-split[data-ntx-id="${ntxId}"]`);
|
||||
if (!noteContainer?.contains(document.activeElement)) {
|
||||
this.triggerEvent("focusOnDetail", { ntxId });
|
||||
}
|
||||
}
|
||||
|
||||
options.save("leftPaneVisible", this.currentLeftPaneVisible.toString());
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import FlexContainer from "./flex_container.js";
|
||||
import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import type NoteContext from "../../components/note_context.js";
|
||||
import Component from "../../components/component.js";
|
||||
import splitService from "../../services/resizer.js";
|
||||
interface NoteContextEvent {
|
||||
noteContext: NoteContext;
|
||||
}
|
||||
|
||||
interface SplitNoteWidget extends BasicWidget {
|
||||
hasBeenAlreadyShown?: boolean;
|
||||
|
||||
@ -5,14 +5,24 @@ body.popup-editor-open .ck-clipboard-drop-target-line { z-index: 1000; }
|
||||
|
||||
body.desktop .modal.popup-editor-dialog .modal-dialog {
|
||||
max-width: 75vw;
|
||||
}
|
||||
|
||||
.modal.popup-editor-dialog .modal-dialog {
|
||||
border-bottom-left-radius: var(--bs-modal-border-radius);
|
||||
border-bottom-right-radius: var(--bs-modal-border-radius);
|
||||
}
|
||||
|
||||
body.desktop .modal.popup-editor-dialog .modal-dialog {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.mobile .modal.popup-editor-dialog .modal-dialog {
|
||||
max-width: min(var(--preferred-max-content-width), 95vw);
|
||||
max-height: var(--tn-modal-max-height);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal.popup-editor-dialog .modal-content {
|
||||
transition: background-color 250ms ease-in;
|
||||
}
|
||||
|
||||
.modal.popup-editor-dialog .modal-header .modal-title {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
@ -52,12 +62,16 @@ body.desktop .modal.popup-editor-dialog .modal-dialog {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.modal.popup-editor-dialog .classic-toolbar-outer-container.visible {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.modal.popup-editor-dialog .classic-toolbar-widget {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
background: var(--modal-background-color);
|
||||
background: transparent;
|
||||
z-index: 998;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import "./PopupEditor.css";
|
||||
import { useNoteContext, useTriliumEvent } from "../react/hooks";
|
||||
import { useNoteContext, useNoteLabel, useTriliumEvent } from "../react/hooks";
|
||||
import NoteTitleWidget from "../note_title";
|
||||
import NoteIcon from "../note_icon";
|
||||
import NoteContext from "../../components/note_context";
|
||||
@ -89,17 +89,10 @@ export default function PopupEditor() {
|
||||
export function DialogWrapper({ children }: { children: ComponentChildren }) {
|
||||
const { note } = useNoteContext();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [ hasTint, setHasTint ] = useState(false);
|
||||
|
||||
// Apply the tinted-dialog class only if the custom color CSS class specifies a hue
|
||||
useEffect(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
const customHue = getComputedStyle(wrapperRef.current).getPropertyValue("--custom-color-hue");
|
||||
setHasTint(!!customHue);
|
||||
}, [ note ]);
|
||||
useNoteLabel(note, "color"); // to update color class
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} class={`quick-edit-dialog-wrapper ${note?.getColorClass() ?? ""} ${hasTint ? "tinted-quick-edit-dialog" : ""}`}>
|
||||
<div ref={wrapperRef} class={`quick-edit-dialog-wrapper ${note?.getColorClass() ?? ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -31,29 +31,29 @@ export default function AboutDialog() {
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{t("about.homepage")}</th>
|
||||
<td><a className="tn-link external" href="https://github.com/TriliumNext/Trilium" style={forceWordBreak}>https://github.com/TriliumNext/Trilium</a></td>
|
||||
<td className="selectable-text"><a className="tn-link external" href="https://github.com/TriliumNext/Trilium" style={forceWordBreak}>https://github.com/TriliumNext/Trilium</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.app_version")}</th>
|
||||
<td className="app-version">{appInfo?.appVersion}</td>
|
||||
<td className="app-version selectable-text">{appInfo?.appVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.db_version")}</th>
|
||||
<td className="db-version">{appInfo?.dbVersion}</td>
|
||||
<td className="db-version selectable-text">{appInfo?.dbVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.sync_version")}</th>
|
||||
<td className="sync-version">{appInfo?.syncVersion}</td>
|
||||
<td className="sync-version selectable-text">{appInfo?.syncVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.build_date")}</th>
|
||||
<td className="build-date">
|
||||
<td className="build-date selectable-text">
|
||||
{appInfo?.buildDate ? formatDateTime(appInfo.buildDate) : ""}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.build_revision")}</th>
|
||||
<td>
|
||||
<td className="selectable-text">
|
||||
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>}
|
||||
</td>
|
||||
</tr>
|
||||
@ -76,8 +76,8 @@ function DirectoryLink({ directory, style }: { directory: string, style?: CSSPro
|
||||
openService.openDirectory(directory);
|
||||
};
|
||||
|
||||
return <a className="tn-link" href="#" onClick={onClick} style={style}>{directory}</a>
|
||||
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>
|
||||
} else {
|
||||
return <span style={style}>{directory}</span>;
|
||||
return <span className="selectable-text" style={style}>{directory}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -208,7 +208,7 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
|
||||
}
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="revision-content use-tn-links" style={{ overflow: "auto", wordBreak: "break-word" }}>
|
||||
<div className="revision-content use-tn-links selectable-text" style={{ overflow: "auto", wordBreak: "break-word" }}>
|
||||
<RevisionContent noteContent={noteContent} revisionItem={revisionItem} fullRevision={fullRevision} showDiff={showDiff}/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -795,7 +795,7 @@ export function useKeyboardShortcuts(scope: "code-detail" | "text-detail", conta
|
||||
* and provides a way to switch to editing mode.
|
||||
*/
|
||||
export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: NoteContext | undefined) {
|
||||
const [isReadOnly, setIsReadOnly] = useState<boolean | undefined>(undefined);
|
||||
const [ isReadOnly, setIsReadOnly ] = useState<boolean | undefined>(undefined);
|
||||
|
||||
const enableEditing = useCallback(() => {
|
||||
if (noteContext?.viewScope) {
|
||||
@ -810,7 +810,7 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
|
||||
setIsReadOnly(readOnly);
|
||||
});
|
||||
}
|
||||
}, [note, noteContext]);
|
||||
}, [ note, noteContext, noteContext?.viewScope ]);
|
||||
|
||||
useTriliumEvent("readOnlyTemporarilyDisabled", ({noteContext: eventNoteContext}) => {
|
||||
if (noteContext?.ntxId === eventNoteContext.ntxId) {
|
||||
@ -818,7 +818,7 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
|
||||
}
|
||||
});
|
||||
|
||||
return {isReadOnly, enableEditing};
|
||||
return { isReadOnly, enableEditing };
|
||||
}
|
||||
|
||||
async function isNoteReadOnly(note: FNote, noteContext: NoteContext) {
|
||||
|
||||
@ -17,24 +17,24 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
|
||||
return (
|
||||
<div className="file-properties-widget">
|
||||
{note && (
|
||||
<table class="file-table">
|
||||
<table className="file-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="text-nowrap">{t("file_properties.note_id")}:</th>
|
||||
<td class="file-note-id">{note.noteId}</td>
|
||||
<th class="text-nowrap">{t("file_properties.original_file_name")}:</th>
|
||||
<td class="file-filename">{originalFileName ?? "?"}</td>
|
||||
<th className="text-nowrap">{t("file_properties.note_id")}:</th>
|
||||
<td className="file-note-id selectable-text">{note.noteId}</td>
|
||||
<th className="text-nowrap">{t("file_properties.original_file_name")}:</th>
|
||||
<td className="file-filename selectable-text">{originalFileName ?? "?"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap">{t("file_properties.file_type")}:</th>
|
||||
<td class="file-filetype">{note.mime}</td>
|
||||
<th class="text-nowrap">{t("file_properties.file_size")}:</th>
|
||||
<td class="file-filesize">{formatSize(blob?.contentLength ?? 0)}</td>
|
||||
<th className="text-nowrap">{t("file_properties.file_type")}:</th>
|
||||
<td className="file-filetype selectable-text">{note.mime}</td>
|
||||
<th className="text-nowrap">{t("file_properties.file_size")}:</th>
|
||||
<td className="file-filesize selectable-text">{formatSize(blob?.contentLength ?? 0)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<div class="file-buttons">
|
||||
<div className="file-buttons">
|
||||
<Button
|
||||
icon="bx bx-download"
|
||||
text={t("file_properties.download")}
|
||||
|
||||
@ -23,17 +23,17 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
|
||||
<div style={{ display: "flex", justifyContent: "space-evenly", margin: "10px" }}>
|
||||
<span>
|
||||
<strong>{t("image_properties.original_file_name")}:</strong>{" "}
|
||||
<span>{originalFileName ?? "?"}</span>
|
||||
<span className="selectable-text">{originalFileName ?? "?"}</span>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<strong>{t("image_properties.file_type")}:</strong>{" "}
|
||||
<span>{note.mime}</span>
|
||||
<span className="selectable-text">{note.mime}</span>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<strong>{t("image_properties.file_size")}:</strong>{" "}
|
||||
<span>{formatSize(blob?.contentLength)}</span>
|
||||
<span className="selectable-text">{formatSize(blob?.contentLength)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ export default function InheritedAttributesTab({ note, componentId }: TabContext
|
||||
|
||||
return (
|
||||
<div className="inherited-attributes-widget">
|
||||
<div className="inherited-attributes-container">
|
||||
<div className="inherited-attributes-container selectable-text">
|
||||
{inheritedAttributes?.length ? (
|
||||
joinElements(inheritedAttributes.map(attribute => (
|
||||
<InheritedAttribute
|
||||
|
||||
@ -39,21 +39,21 @@ export default function NoteInfoTab({ note }: TabContext) {
|
||||
<>
|
||||
<div className="note-info-item">
|
||||
<span>{t("note_info_widget.note_id")}:</span>
|
||||
<span className="note-info-id">{note.noteId}</span>
|
||||
<span className="note-info-id selectable-text">{note.noteId}</span>
|
||||
</div>
|
||||
<div className="note-info-item">
|
||||
<span>{t("note_info_widget.created")}:</span>
|
||||
<span>{formatDateTime(metadata?.dateCreated)}</span>
|
||||
<span className="selectable-text">{formatDateTime(metadata?.dateCreated)}</span>
|
||||
</div>
|
||||
<div className="note-info-item">
|
||||
<span>{t("note_info_widget.modified")}:</span>
|
||||
<span>{formatDateTime(metadata?.dateModified)}</span>
|
||||
<span className="selectable-text">{formatDateTime(metadata?.dateModified)}</span>
|
||||
</div>
|
||||
<div className="note-info-item">
|
||||
<span>{t("note_info_widget.type")}:</span>
|
||||
<span>
|
||||
<span className="note-info-type">{note.type}</span>{' '}
|
||||
{note.mime && <span className="note-info-mime">({note.mime})</span>}
|
||||
{note.mime && <span className="note-info-mime selectable-text">({note.mime})</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="note-info-item">
|
||||
@ -77,7 +77,7 @@ export default function NoteInfoTab({ note }: TabContext) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="note-sizes-wrapper">
|
||||
<span className="note-sizes-wrapper selectable-text">
|
||||
<span className="note-size">{formatSize(noteSizeResponse?.noteSize)}</span>
|
||||
{" "}
|
||||
{subtreeSizeResponse && subtreeSizeResponse.subTreeNoteCount > 1 &&
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
.note-detail-doc-content {
|
||||
padding: 15px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.note-detail-doc-content pre {
|
||||
|
||||
@ -1,3 +1,21 @@
|
||||
.note-detail-empty {
|
||||
container-type: size;
|
||||
padding-top: 50px;
|
||||
min-width: 350px;
|
||||
}
|
||||
|
||||
.note-detail-empty > * {
|
||||
margin-inline: auto;
|
||||
max-width: 850px;
|
||||
padding-inline: 50px;
|
||||
}
|
||||
|
||||
@container (max-width: 600px) {
|
||||
.note-detail-empty > * {
|
||||
padding-inline: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-notes {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -14,7 +32,8 @@
|
||||
|
||||
.workspace-notes .workspace-note:hover {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--main-border-color);
|
||||
background-color: var(--icon-button-hover-background);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.note-detail-empty-results .aa-dropdown-menu {
|
||||
@ -24,6 +43,11 @@
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.empty-tab-search label {
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.empty-tab-search .note-autocomplete-input {
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
@ -10,16 +10,16 @@ import FNote from "../../entities/fnote";
|
||||
import search from "../../services/search";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
export default function Empty({ }: TypeWidgetProps) {
|
||||
export default function Empty({ ntxId }: TypeWidgetProps) {
|
||||
return (
|
||||
<>
|
||||
<WorkspaceSwitcher />
|
||||
<NoteSearch />
|
||||
<NoteSearch ntxId={ntxId ?? null} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteSearch() {
|
||||
function NoteSearch({ ntxId }: { ntxId: string | null }) {
|
||||
const resultsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const autocompleteRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@ -45,10 +45,9 @@ function NoteSearch() {
|
||||
if (!suggestion?.notePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (activeContext) {
|
||||
activeContext.setNote(suggestion.notePath);
|
||||
const activeNoteContext = appContext.tabManager.getNoteContextById(ntxId) ?? appContext.tabManager.getActiveContext();
|
||||
if (activeNoteContext) {
|
||||
activeNoteContext.setNote(suggestion.notePath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
10
apps/client/src/widgets/type_widgets/NoteMap.css
Normal file
10
apps/client/src/widgets/type_widgets/NoteMap.css
Normal file
@ -0,0 +1,10 @@
|
||||
.note-detail-note-map {
|
||||
&>div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import NoteMapEl from "../note_map/NoteMap";
|
||||
import { useRef } from "preact/hooks";
|
||||
import "./NoteMap.css";
|
||||
|
||||
export default function NoteMap({ note }: TypeWidgetProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -104,7 +104,7 @@ export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
|
||||
backups.map(({ mtime, filePath }) => (
|
||||
<tr>
|
||||
<td>{mtime ? formatDateTime(mtime) : "-"}</td>
|
||||
<td>{filePath}</td>
|
||||
<td className="selectable-text">{filePath}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
|
||||
@ -226,7 +226,7 @@ function CodeBlockPreview({ theme, wordWrap }: { theme: string, wordWrap: boolea
|
||||
|
||||
return (
|
||||
<div className="note-detail-readonly-text-content ck-content code-sample-wrapper">
|
||||
<pre className="hljs" style={{ marginBottom: 0 }}>
|
||||
<pre className="hljs selectable-text" style={{ marginBottom: 0 }}>
|
||||
<code className="code-sample" style={codeStyle} dangerouslySetInnerHTML={getHtml(code)} />
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@ -98,6 +98,14 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
|
||||
editorApi: editorApiRef.current,
|
||||
});
|
||||
},
|
||||
insertDateTimeToTextCommand() {
|
||||
if (!editorApiRef.current) return;
|
||||
const date = new Date();
|
||||
const customDateTimeFormat = options.get("customDateTimeFormat");
|
||||
const dateString = utils.formatDateTime(date, customDateTimeFormat);
|
||||
|
||||
addTextToEditor(dateString);
|
||||
},
|
||||
// Include note functionality note
|
||||
addIncludeNoteToTextCommand() {
|
||||
if (!editorApiRef.current) return;
|
||||
@ -197,14 +205,6 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
|
||||
});
|
||||
}
|
||||
|
||||
useTriliumEvent("insertDateTimeToText", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
const date = new Date();
|
||||
const customDateTimeFormat = options.get("customDateTimeFormat");
|
||||
const dateString = utils.formatDateTime(date, customDateTimeFormat);
|
||||
|
||||
addTextToEditor(dateString);
|
||||
});
|
||||
useTriliumEvent("addTextToActiveEditor", ({ text }) => {
|
||||
if (!noteContext?.isActive()) return;
|
||||
addTextToEditor(text);
|
||||
|
||||
@ -55,7 +55,7 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro
|
||||
<>
|
||||
<RawHtmlBlock
|
||||
containerRef={contentRef}
|
||||
className={clsx("note-detail-readonly-text-content ck-content use-tn-links", codeBlockWordWrap && "word-wrap")}
|
||||
className={clsx("note-detail-readonly-text-content ck-content use-tn-links selectable-text", codeBlockWordWrap && "word-wrap")}
|
||||
tabindex={100}
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
html={blob?.content}
|
||||
|
||||
72
apps/server-e2e/src/layout/split_pane.spec.ts
Normal file
72
apps/server-e2e/src/layout/split_pane.spec.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import App from "../support/app";
|
||||
|
||||
const TEXT_NOTE_TITLE = "Text notes";
|
||||
const CODE_NOTE_TITLE = "Code notes";
|
||||
|
||||
test("Open the note in the correct split pane", async ({ page, context }) => {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.closeAllTabs();
|
||||
|
||||
// Open the first split.
|
||||
await app.goToNoteInNewTab(TEXT_NOTE_TITLE);
|
||||
const split1 = app.currentNoteSplit;
|
||||
|
||||
// Create a new split.
|
||||
const splitButton = split1.locator("button.bx-dock-right");
|
||||
await expect(splitButton).toBeVisible();
|
||||
await splitButton.click();
|
||||
|
||||
// Search for "Code notes" in the empty area of the second split.
|
||||
const split2 = app.currentNoteSplit.nth(1);;
|
||||
await expect(split2).toBeVisible();
|
||||
const autocomplete = split2.locator(".note-autocomplete");
|
||||
await autocomplete.fill(CODE_NOTE_TITLE);
|
||||
const resultsSelector = split2.locator(".note-detail-empty-results");
|
||||
await expect(resultsSelector).toContainText(CODE_NOTE_TITLE);
|
||||
|
||||
//Focus on the first split.
|
||||
const noteContent = split1.locator(".note-detail-editable-text-editor");
|
||||
await expect(noteContent.locator("p")).toBeVisible();
|
||||
await noteContent.focus();
|
||||
|
||||
// Click the search result in the second split.
|
||||
await resultsSelector.locator(".aa-suggestion", { hasText: CODE_NOTE_TITLE })
|
||||
.nth(1).click();
|
||||
|
||||
await expect(split2).toContainText(CODE_NOTE_TITLE);
|
||||
});
|
||||
|
||||
test("Can directly focus the autocomplete input within the split", async ({ page, context }) => {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.closeAllTabs();
|
||||
|
||||
// Open the first split.
|
||||
await app.goToNoteInNewTab(TEXT_NOTE_TITLE);
|
||||
const split1 = app.currentNoteSplit;
|
||||
|
||||
// Create a new split.
|
||||
const splitButton = split1.locator("button.bx-dock-right");
|
||||
await expect(splitButton).toBeVisible();
|
||||
await splitButton.click();
|
||||
|
||||
// Search for "Code notes" in the empty area of the second split.
|
||||
const split2 = app.currentNoteSplit.nth(1);;
|
||||
await expect(split2).toBeVisible();
|
||||
|
||||
// Focus the first split.
|
||||
const noteContent = split1.locator(".note-detail-editable-text-editor");
|
||||
await expect(noteContent.locator("p")).toBeVisible();
|
||||
await noteContent.focus();
|
||||
await noteContent.click();
|
||||
|
||||
// click the autocomplete input box of the second split
|
||||
const autocomplete = split2.locator(".note-autocomplete");
|
||||
await autocomplete.focus();
|
||||
await autocomplete.click();
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await expect(autocomplete).toBeFocused();
|
||||
});
|
||||
@ -13,10 +13,6 @@ import BBlob from "./entities/bblob.js";
|
||||
import BRecentNote from "./entities/brecent_note.js";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
|
||||
interface AttachmentOpts {
|
||||
includeContentLength?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Becca is a backend cache of all notes, branches, and attributes.
|
||||
* There's a similar frontend cache Froca, and share cache Shaca.
|
||||
@ -167,21 +163,18 @@ export default class Becca {
|
||||
return revision;
|
||||
}
|
||||
|
||||
getAttachment(attachmentId: string, opts: AttachmentOpts = {}): BAttachment | null {
|
||||
opts.includeContentLength = !!opts.includeContentLength;
|
||||
|
||||
const query = opts.includeContentLength
|
||||
? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
||||
FROM attachments
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE attachmentId = ? AND isDeleted = 0`
|
||||
: /*sql*/`SELECT * FROM attachments WHERE attachmentId = ? AND isDeleted = 0`;
|
||||
getAttachment(attachmentId: string): BAttachment | null {
|
||||
const query = /*sql*/`\
|
||||
SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
||||
FROM attachments
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE attachmentId = ? AND isDeleted = 0`;
|
||||
|
||||
return sql.getRows<AttachmentRow>(query, [attachmentId]).map((row) => new BAttachment(row))[0];
|
||||
}
|
||||
|
||||
getAttachmentOrThrow(attachmentId: string, opts: AttachmentOpts = {}): BAttachment {
|
||||
const attachment = this.getAttachment(attachmentId, opts);
|
||||
getAttachmentOrThrow(attachmentId: string): BAttachment {
|
||||
const attachment = this.getAttachment(attachmentId);
|
||||
if (!attachment) {
|
||||
throw new NotFoundError(`Attachment '${attachmentId}' has not been found.`);
|
||||
}
|
||||
|
||||
@ -61,10 +61,6 @@ interface ContentOpts {
|
||||
forceFrontendReload?: boolean;
|
||||
}
|
||||
|
||||
interface AttachmentOpts {
|
||||
includeContentLength?: boolean;
|
||||
}
|
||||
|
||||
interface Relationship {
|
||||
parentNoteId: string;
|
||||
childNoteId: string;
|
||||
@ -1102,31 +1098,23 @@ class BNote extends AbstractBeccaEntity<BNote> {
|
||||
return sql.getRows<RevisionRow>("SELECT * FROM revisions WHERE noteId = ? ORDER BY revisions.utcDateCreated ASC", [this.noteId]).map((row) => new BRevision(row));
|
||||
}
|
||||
|
||||
getAttachments(opts: AttachmentOpts = {}) {
|
||||
opts.includeContentLength = !!opts.includeContentLength;
|
||||
// from testing, it looks like calculating length does not make a difference in performance even on large-ish DB
|
||||
// given that we're always fetching attachments only for a specific note, we might just do it always
|
||||
|
||||
const query = opts.includeContentLength
|
||||
? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
||||
FROM attachments
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE ownerId = ? AND isDeleted = 0
|
||||
ORDER BY position`
|
||||
: /*sql*/`SELECT * FROM attachments WHERE ownerId = ? AND isDeleted = 0 ORDER BY position`;
|
||||
getAttachments() {
|
||||
const query = /*sql*/`\
|
||||
SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
||||
FROM attachments
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE ownerId = ? AND isDeleted = 0
|
||||
ORDER BY position`;
|
||||
|
||||
return sql.getRows<AttachmentRow>(query, [this.noteId]).map((row) => new BAttachment(row));
|
||||
}
|
||||
|
||||
getAttachmentById(attachmentId: string, opts: AttachmentOpts = {}) {
|
||||
opts.includeContentLength = !!opts.includeContentLength;
|
||||
|
||||
const query = opts.includeContentLength
|
||||
? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
||||
FROM attachments
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`
|
||||
: /*sql*/`SELECT * FROM attachments WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
|
||||
getAttachmentById(attachmentId: string) {
|
||||
const query = /*sql*/`\
|
||||
SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
||||
FROM attachments
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
|
||||
|
||||
return sql.getRows<AttachmentRow>(query, [this.noteId, attachmentId]).map((row) => new BAttachment(row))[0];
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ function getAndCheckNote(noteId: string) {
|
||||
}
|
||||
|
||||
function getAndCheckAttachment(attachmentId: string) {
|
||||
const attachment = becca.getAttachment(attachmentId, { includeContentLength: true });
|
||||
const attachment = becca.getAttachment(attachmentId);
|
||||
|
||||
if (attachment) {
|
||||
return attachment;
|
||||
|
||||
@ -185,7 +185,7 @@ function register(router: Router) {
|
||||
|
||||
eu.route(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
const attachments = note.getAttachments({ includeContentLength: true });
|
||||
const attachments = note.getAttachments();
|
||||
|
||||
res.json(attachments.map((attachment) => mappers.mapAttachmentToPojo(attachment)));
|
||||
});
|
||||
|
||||
@ -14,13 +14,13 @@ function getAttachmentBlob(req: Request) {
|
||||
function getAttachments(req: Request) {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
return note.getAttachments({ includeContentLength: true });
|
||||
return note.getAttachments();
|
||||
}
|
||||
|
||||
function getAttachment(req: Request) {
|
||||
const { attachmentId } = req.params;
|
||||
|
||||
return becca.getAttachmentOrThrow(attachmentId, { includeContentLength: true });
|
||||
return becca.getAttachmentOrThrow(attachmentId);
|
||||
}
|
||||
|
||||
function getAllAttachments(req: Request) {
|
||||
@ -28,7 +28,7 @@ function getAllAttachments(req: Request) {
|
||||
// one particular attachment is requested, but return all note's attachments
|
||||
|
||||
const attachment = becca.getAttachmentOrThrow(attachmentId);
|
||||
return attachment.getNote()?.getAttachments({ includeContentLength: true }) || [];
|
||||
return attachment.getNote()?.getAttachments() || [];
|
||||
}
|
||||
|
||||
function saveAttachment(req: Request) {
|
||||
|
||||
@ -764,7 +764,7 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment
|
||||
note.setContent(newContent, { forceFrontendReload });
|
||||
|
||||
if (attachments?.length > 0) {
|
||||
const existingAttachmentsByTitle = toMap(note.getAttachments({ includeContentLength: false }), "title");
|
||||
const existingAttachmentsByTitle = toMap(note.getAttachments(), "title");
|
||||
|
||||
for (const { attachmentId, role, mime, title, position, content } of attachments) {
|
||||
const existingAttachment = existingAttachmentsByTitle.get(title);
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { note, NoteBuilder } from "../test/becca_mocking.js";
|
||||
import {beforeEach, describe, expect, it, vi} from "vitest";
|
||||
import {note, NoteBuilder} from "../test/becca_mocking.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import tree from "./tree.js";
|
||||
import cls from "./cls.js";
|
||||
import { buildNote } from "../test/becca_easy_mocking.js";
|
||||
import {buildNote} from "../test/becca_easy_mocking.js";
|
||||
|
||||
describe("Tree", () => {
|
||||
let rootNote!: NoteBuilder;
|
||||
@ -48,6 +48,23 @@ describe("Tree", () => {
|
||||
};
|
||||
});
|
||||
});
|
||||
it("sorts notes by title (base case)", () => {
|
||||
|
||||
const note = buildNote({
|
||||
children: [
|
||||
{title: "1"},
|
||||
{title: "2"},
|
||||
{title: "3"},
|
||||
],
|
||||
"#sorted": "",
|
||||
});
|
||||
cls.init(() => {
|
||||
tree.sortNotesIfNeeded(note.noteId);
|
||||
});
|
||||
const orderedTitles = note.children.map((child) => child.title);
|
||||
expect(orderedTitles).toStrictEqual(["1", "2", "3"]);
|
||||
}
|
||||
)
|
||||
|
||||
it("custom sort order is idempotent", () => {
|
||||
rootNote.label("sorted", "order");
|
||||
@ -56,13 +73,15 @@ describe("Tree", () => {
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
rootNote.child(note(String(i)).label("order", String(i)));
|
||||
}
|
||||
rootNote.child(note("top").label("top"));
|
||||
rootNote.child(note("bottom").label("bottom"));
|
||||
|
||||
// Add a few values which have no defined order.
|
||||
for (let i = 6; i < 10; i++) {
|
||||
rootNote.child(note(String(i)));
|
||||
}
|
||||
|
||||
const expectedOrder = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" ];
|
||||
const expectedOrder = ["top", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "bottom"];
|
||||
|
||||
// Sort a few times to ensure that the resulting order is the same.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
@ -78,12 +97,12 @@ describe("Tree", () => {
|
||||
it("pins to the top and bottom", () => {
|
||||
const note = buildNote({
|
||||
children: [
|
||||
{ title: "bottom", "#bottom": "" },
|
||||
{ title: "5" },
|
||||
{ title: "3" },
|
||||
{ title: "2" },
|
||||
{ title: "1" },
|
||||
{ title: "top", "#top": "" }
|
||||
{title: "bottom", "#bottom": ""},
|
||||
{title: "5"},
|
||||
{title: "3"},
|
||||
{title: "2"},
|
||||
{title: "1"},
|
||||
{title: "top", "#top": ""}
|
||||
],
|
||||
"#sorted": ""
|
||||
});
|
||||
@ -91,18 +110,18 @@ describe("Tree", () => {
|
||||
tree.sortNotesIfNeeded(note.noteId);
|
||||
});
|
||||
const orderedTitles = note.children.map((child) => child.title);
|
||||
expect(orderedTitles).toStrictEqual([ "top", "1", "2", "3", "5", "bottom" ]);
|
||||
expect(orderedTitles).toStrictEqual(["top", "1", "2", "3", "5", "bottom"]);
|
||||
});
|
||||
|
||||
it("pins to the top and bottom in reverse order", () => {
|
||||
const note = buildNote({
|
||||
children: [
|
||||
{ title: "bottom", "#bottom": "" },
|
||||
{ title: "1" },
|
||||
{ title: "2" },
|
||||
{ title: "3" },
|
||||
{ title: "5" },
|
||||
{ title: "top", "#top": "" }
|
||||
{title: "bottom", "#bottom": ""},
|
||||
{title: "1"},
|
||||
{title: "2"},
|
||||
{title: "3"},
|
||||
{title: "5"},
|
||||
{title: "top", "#top": ""}
|
||||
],
|
||||
"#sorted": "",
|
||||
"#sortDirection": "desc"
|
||||
@ -111,6 +130,50 @@ describe("Tree", () => {
|
||||
tree.sortNotesIfNeeded(note.noteId);
|
||||
});
|
||||
const orderedTitles = note.children.map((child) => child.title);
|
||||
expect(orderedTitles).toStrictEqual([ "top", "5", "3", "2", "1", "bottom" ]);
|
||||
expect(orderedTitles).toStrictEqual(["top", "5", "3", "2", "1", "bottom"]);
|
||||
});
|
||||
|
||||
it("keeps folder notes on top when #sortFolderFirst is set, but not above #top", () => {
|
||||
const note = buildNote({
|
||||
children: [
|
||||
{title: "bottom", "#bottom": ""},
|
||||
{title: "1"},
|
||||
{title: "2"},
|
||||
{title: "p1", children: [{title: "1.1"}, {title: "1.2"}]},
|
||||
{title: "p2", children: [{title: "2.1"}, {title: "2.2"}]},
|
||||
{title: "3"},
|
||||
{title: "5"},
|
||||
{title: "top", "#top": ""}
|
||||
],
|
||||
"#sorted": "",
|
||||
"#sortFoldersFirst": ""
|
||||
});
|
||||
cls.init(() => {
|
||||
tree.sortNotesIfNeeded(note.noteId);
|
||||
});
|
||||
const orderedTitles = note.children.map((child) => child.title);
|
||||
expect(orderedTitles).toStrictEqual(["top", "p1", "p2", "1", "2", "3", "5", "bottom"]);
|
||||
});
|
||||
|
||||
it("sorts notes accordingly when #sortNatural is set", () => {
|
||||
const note = buildNote({
|
||||
children: [
|
||||
{title: "bottom", "#bottom": ""},
|
||||
{title: "1"},
|
||||
{title: "2"},
|
||||
{title: "10"},
|
||||
{title: "20"},
|
||||
{title: "3"},
|
||||
{title: "top", "#top": ""}
|
||||
],
|
||||
"#sorted": "",
|
||||
"#sortNatural": ""
|
||||
});
|
||||
cls.init(() => {
|
||||
tree.sortNotesIfNeeded(note.noteId);
|
||||
});
|
||||
const orderedTitles = note.children.map((child) => child.title);
|
||||
expect(orderedTitles).toStrictEqual(["top", "1", "2", "3", "10", "20", "bottom"]);
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
@ -98,15 +98,6 @@ function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse
|
||||
}
|
||||
|
||||
notes.sort((a, b) => {
|
||||
if (foldersFirst) {
|
||||
const aHasChildren = a.hasChildren();
|
||||
const bHasChildren = b.hasChildren();
|
||||
|
||||
if ((aHasChildren && !bHasChildren) || (!aHasChildren && bHasChildren)) {
|
||||
// exactly one note of the two is a directory, so the sorting will be done based on this status
|
||||
return aHasChildren ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchValue(note: BNote, key: string) {
|
||||
let rawValue: string | null;
|
||||
@ -154,6 +145,16 @@ function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse
|
||||
return compare(bottomBEl, bottomAEl) * (reverse ? -1 : 1);
|
||||
}
|
||||
|
||||
if (foldersFirst) {
|
||||
const aHasChildren = a.hasChildren();
|
||||
const bHasChildren = b.hasChildren();
|
||||
|
||||
if ((aHasChildren && !bHasChildren) || (!aHasChildren && bHasChildren)) {
|
||||
// exactly one note of the two is a directory, so the sorting will be done based on this status
|
||||
return aHasChildren ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
const customAEl = fetchValue(a, customSortBy) ?? fetchValue(a, "title") as string;
|
||||
const customBEl = fetchValue(b, customSortBy) ?? fetchValue(b, "title") as string;
|
||||
|
||||
|
||||
@ -85,5 +85,8 @@
|
||||
"title_arm64": "ARM 기반 리눅스",
|
||||
"description_x64": "대부분의 리눅스 배포판에서 x86_64 아키텍처와 호환됩니다.",
|
||||
"description_arm64": "ARM 기반 리눅스 배포판에서 aarch64 아키텍처와 호환됩니다."
|
||||
},
|
||||
"note_types": {
|
||||
"text_title": "텍스트 노트"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user