New layout improvements (#8012)
Some checks are pending
Checks / main (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Dev / Test development (push) Waiting to run
Dev / Build Docker image (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile) (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile.alpine) (push) Blocked by required conditions
/ Check Docker build (Dockerfile) (push) Waiting to run
/ Check Docker build (Dockerfile.alpine) (push) Waiting to run
/ Build Docker images (Dockerfile, ubuntu-24.04-arm, linux/arm64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.alpine, ubuntu-latest, linux/amd64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v7) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v8) (push) Blocked by required conditions
/ Merge manifest lists (push) Blocked by required conditions
playwright / E2E tests on linux-arm64 (push) Waiting to run
playwright / E2E tests on linux-x64 (push) Waiting to run

This commit is contained in:
Elian Doran 2025-12-10 17:50:31 +02:00 committed by GitHub
commit 84bde62e05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 541 additions and 165 deletions

View File

@ -140,7 +140,7 @@ export default class DesktopLayout {
.class("breadcrumb-row") .class("breadcrumb-row")
.cssBlock(".breadcrumb-row > * { margin: 5px; }") .cssBlock(".breadcrumb-row > * { margin: 5px; }")
.child(<Breadcrumb />) .child(<Breadcrumb />)
.child(<BreadcrumbBadges />) .optChild(isNewLayout, <BreadcrumbBadges />)
.child(<SpacerWidget baseSize={0} growthFactor={1} />) .child(<SpacerWidget baseSize={0} growthFactor={1} />)
.child(<MovePaneButton direction="left" />) .child(<MovePaneButton direction="left" />)
.child(<MovePaneButton direction="right" />) .child(<MovePaneButton direction="right" />)

View File

@ -52,5 +52,5 @@ export function applyModals(rootContainer: RootContainer) {
.child(<IncorrectCpuArchDialog />) .child(<IncorrectCpuArchDialog />)
.child(<PopupEditorDialog />) .child(<PopupEditorDialog />)
.child(<CallToActionDialog />) .child(<CallToActionDialog />)
.child(<ToastContainer />) .child(<ToastContainer />);
} }

View File

@ -1321,6 +1321,11 @@ body.desktop li.dropdown-submenu:hover > ul.dropdown-menu {
overflow: auto; overflow: auto;
} }
.dropdown-submenu.dropstart > .dropdown-menu {
inset-inline-start: auto;
inset-inline-end: calc(100% - 2px);
}
body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu { body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
inset-inline-start: calc(-100% + 10px); inset-inline-start: calc(-100% + 10px);
} }

View File

@ -89,13 +89,13 @@
* the color is adjusted based on the current color scheme (light or dark). The lightness * the color is adjusted based on the current color scheme (light or dark). The lightness
* component of the color represented in the CIELAB color space, will be * component of the color represented in the CIELAB color space, will be
* constrained to a certain percentage defined below. * constrained to a certain percentage defined below.
* *
* Note: the tree background may vary when background effects are enabled, so it is recommended * Note: the tree background may vary when background effects are enabled, so it is recommended
* to maintain a higher contrast margin than on the usual note tree solid background. */ * to maintain a higher contrast margin than on the usual note tree solid background. */
/* The maximum perceptual lightness for the custom color in the light theme (%): */ /* The maximum perceptual lightness for the custom color in the light theme (%): */
--tree-item-light-theme-max-color-lightness: 60; --tree-item-light-theme-max-color-lightness: 60;
/* The minimum perceptual lightness for the custom color in the dark theme (%): */ /* The minimum perceptual lightness for the custom color in the dark theme (%): */
--tree-item-dark-theme-min-color-lightness: 65; --tree-item-dark-theme-min-color-lightness: 65;
} }
@ -165,7 +165,7 @@ body.desktop .dropdown-submenu .dropdown-menu {
--menu-item-start-padding: 8px; --menu-item-start-padding: 8px;
--menu-item-end-padding: 22px; --menu-item-end-padding: 22px;
--menu-item-vertical-padding: 2px; --menu-item-vertical-padding: 2px;
padding-top: var(--menu-item-vertical-padding) !important; padding-top: var(--menu-item-vertical-padding) !important;
padding-bottom: var(--menu-item-vertical-padding) !important; padding-bottom: var(--menu-item-vertical-padding) !important;
padding-inline-start: var(--menu-item-start-padding) !important; padding-inline-start: var(--menu-item-start-padding) !important;
@ -176,6 +176,11 @@ body.desktop .dropdown-submenu .dropdown-menu {
cursor: default !important; cursor: default !important;
} }
.dropdown-menu:has(> .dropdown-submenu.dropstart) > .dropdown-item {
padding-inline-end: var(--menu-item-start-padding) !important;
padding-inline-start: var(--menu-item-end-padding) !important;
}
:root .dropdown-item:focus-visible { :root .dropdown-item:focus-visible {
outline: 2px solid var(--input-focus-outline-color) !important; outline: 2px solid var(--input-focus-outline-color) !important;
background-color: transparent; background-color: transparent;
@ -249,7 +254,7 @@ html body .dropdown-item[disabled] {
} }
/* Menu item arrow */ /* Menu item arrow */
.dropdown-menu .dropdown-toggle::after { .dropdown-submenu:not(.dropstart) .dropdown-toggle::after {
content: "\ed3b" !important; content: "\ed3b" !important;
position: absolute; position: absolute;
display: flex !important; display: flex !important;
@ -265,6 +270,22 @@ html body .dropdown-item[disabled] {
color: var(--menu-item-arrow-color) !important; color: var(--menu-item-arrow-color) !important;
} }
.dropdown-submenu.dropstart .dropdown-toggle::before {
content: "\ea4d" !important;
position: absolute;
display: flex !important;
align-items: center;
justify-content: center;
top: 0;
inset-inline-start: 0;
margin: unset !important;
border: unset !important;
padding: 0 4px;
font-family: boxicons;
font-size: 1.2em;
color: var(--menu-item-arrow-color) !important;
}
body[dir=rtl] .dropdown-menu:not([data-popper-placement="bottom-start"]) .dropdown-toggle::after { body[dir=rtl] .dropdown-menu:not([data-popper-placement="bottom-start"]) .dropdown-toggle::after {
content: "\ea4d" !important; content: "\ea4d" !important;
} }
@ -339,7 +360,7 @@ body.mobile .dropdown-menu {
font-size: 1em !important; font-size: 1em !important;
backdrop-filter: var(--dropdown-backdrop-filter); backdrop-filter: var(--dropdown-backdrop-filter);
position: relative; position: relative;
.dropdown-toggle::after { .dropdown-toggle::after {
top: 0.5em; top: 0.5em;
right: var(--dropdown-menu-padding-horizontal); right: var(--dropdown-menu-padding-horizontal);
@ -356,7 +377,7 @@ body.mobile .dropdown-menu {
padding: var(--dropdown-menu-padding-vertical) var(--dropdown-menu-padding-horizontal) !important; padding: var(--dropdown-menu-padding-vertical) var(--dropdown-menu-padding-horizontal) !important;
background: var(--card-background-color); background: var(--card-background-color);
border-bottom: 1px solid var(--menu-item-delimiter-color) !important; border-bottom: 1px solid var(--menu-item-delimiter-color) !important;
border-radius: 0; border-radius: 0;
} }
.dropdown-item:first-of-type, .dropdown-item:first-of-type,
@ -367,9 +388,9 @@ body.mobile .dropdown-menu {
border-top-right-radius: 6px; border-top-right-radius: 6px;
} }
.dropdown-item:last-of-type, .dropdown-item:last-of-type,
.dropdown-item:has(+ .dropdown-divider), .dropdown-item:has(+ .dropdown-divider),
.dropdown-custom-item:last-of-type, .dropdown-custom-item:last-of-type,
.dropdown-custom-item:has(+ .dropdown-divider) { .dropdown-custom-item:has(+ .dropdown-divider) {
border-bottom-left-radius: 6px; border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px; border-bottom-right-radius: 6px;
@ -392,10 +413,10 @@ body.mobile .dropdown-menu {
--menu-background-color: --menu-submenu-mobile-background-color; --menu-background-color: --menu-submenu-mobile-background-color;
--bs-dropdown-divider-margin-y: 0.25rem; --bs-dropdown-divider-margin-y: 0.25rem;
border-radius: 0; border-radius: 0;
max-height: 0; max-height: 0;
transition: max-height 100ms ease-in; transition: max-height 100ms ease-in;
display: block !important; display: block !important;
&.show { &.show {
max-height: 1000px; max-height: 1000px;
padding: 0.5rem 0.75rem !important; padding: 0.5rem 0.75rem !important;
@ -405,7 +426,7 @@ body.mobile .dropdown-menu {
&.submenu-open { &.submenu-open {
.dropdown-toggle { .dropdown-toggle {
padding-bottom: var(--dropdown-menu-padding-vertical); padding-bottom: var(--dropdown-menu-padding-vertical);
} }
} }
} }
@ -743,4 +764,4 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
.note-detail-empty .aa-suggestions div.aa-cursor { .note-detail-empty .aa-suggestions div.aa-cursor {
background: var(--hover-item-background-color); background: var(--hover-item-background-color);
color: var(--hover-item-text-color); color: var(--hover-item-text-color);
} }

View File

@ -689,6 +689,7 @@
"export_note": "Export note", "export_note": "Export note",
"delete_note": "Delete note", "delete_note": "Delete note",
"print_note": "Print note", "print_note": "Print note",
"view_revisions": "Note revisions...",
"save_revision": "Save revision", "save_revision": "Save revision",
"convert_into_attachment_failed": "Converting note '{{title}}' failed.", "convert_into_attachment_failed": "Converting note '{{title}}' failed.",
"convert_into_attachment_successful": "Note '{{title}}' has been converted to attachment.", "convert_into_attachment_successful": "Note '{{title}}' has been converted to attachment.",
@ -1750,8 +1751,8 @@
}, },
"note_title": { "note_title": {
"placeholder": "type note's title here...", "placeholder": "type note's title here...",
"created_on": "Created on {{date}}", "created_on": "Created on <Value />",
"last_modified": "Last modified on {{date}}" "last_modified": "Last modified on <Value />"
}, },
"search_result": { "search_result": {
"no_notes_found": "No notes have been found for given search parameters.", "no_notes_found": "No notes have been found for given search parameters.",
@ -2132,8 +2133,18 @@
}, },
"breadcrumb_badges": { "breadcrumb_badges": {
"read_only_explicit": "Read-only", "read_only_explicit": "Read-only",
"read_only_explicit_description": "This note has been manually set to read-only.\nClick to edit it temporarily.",
"read_only_auto": "Auto read-only", "read_only_auto": "Auto read-only",
"read_only_auto_description": "This note was set automatically to read-only mode for performance reasons. This automatic limit is adjustable from settings.\n\nClick to edit it temporarily.",
"read_only_temporarily_disabled": "Temporarily editable",
"read_only_temporarily_disabled_description": "This note is currently editable, but it is normally read-only. The note will go back to being read-only as soon as you navigate to another note.\n\nClick to re-enable read-only mode.",
"shared_publicly": "Shared publicly", "shared_publicly": "Shared publicly",
"shared_locally": "Shared locally" "shared_publicly_description": "This note has been published online at {{- link}}, and is publicly accessible.\n\nClick to navigate to the shared note or right click for more options.",
"shared_locally": "Shared locally",
"shared_locally_description": "This note is shared on the local network only at {{- link}}.\n\nClick to navigate to the shared note or right click for more options.",
"backlinks_one": "{{count}} backlink",
"backlinks_other": "{{count}} backlinks",
"backlinks_description_one": "This note is linked from {{count}} other note.\n\nClick to view the list of backlinks.",
"backlinks_description_other": "This note is linked from {{count}} other notes.\n\nClick to view the list of backlinks."
} }
} }

View File

@ -4,6 +4,49 @@
min-height: 30px; min-height: 30px;
align-items: center; align-items: center;
padding: 10px; padding: 10px;
container-type: inline-size;
@container (max-width: 700px) {
.breadcrumb-badges {
flex-shrink: 0;
>* {
flex-shrink: 0;
width: 18px;
}
.dropdown {
button {
flex-shrink: 0;
}
}
.breadcrumb-badge {
flex-shrink: 0;
padding: 0 2px;
>* {
text-overflow: clip;
}
.text {
display: none;
}
}
}
}
@container (max-width: 500px) {
.breadcrumb {
.btn.icon-action {
width: 16px;
}
}
.icon-action {
margin: 0;
}
}
} }
body.experimental-feature-new-layout .breadcrumb-row { body.experimental-feature-new-layout .breadcrumb-row {
@ -53,11 +96,23 @@ body.experimental-feature-new-layout .breadcrumb-row {
} }
.dropdown-item span, .dropdown-item span,
.dropdown-item strong { .dropdown-item strong,
.breadcrumb-last-item {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
display: block; display: block;
max-width: 300px; max-width: 300px;
} }
.breadcrumb-last-item {
text-decoration: none;
color: unset;
cursor: text;
}
input {
padding: 0 10px;
width: 200px;
}
} }

View File

@ -1,6 +1,6 @@
import "./Breadcrumb.css"; import "./Breadcrumb.css";
import { useMemo } from "preact/hooks"; import { useMemo, useState } from "preact/hooks";
import { Fragment } from "preact/jsx-runtime"; import { Fragment } from "preact/jsx-runtime";
import NoteContext from "../components/note_context"; import NoteContext from "../components/note_context";
@ -12,6 +12,8 @@ import { useChildNotes, useNoteContext, useNoteLabel, useNoteProperty } from "./
import Icon from "./react/Icon"; import Icon from "./react/Icon";
import NoteLink from "./react/NoteLink"; import NoteLink from "./react/NoteLink";
import link_context_menu from "../menus/link_context_menu"; import link_context_menu from "../menus/link_context_menu";
import { TitleEditor } from "./collections/board";
import server from "../services/server";
const COLLAPSE_THRESHOLD = 5; const COLLAPSE_THRESHOLD = 5;
const INITIAL_ITEMS = 2; const INITIAL_ITEMS = 2;
@ -27,10 +29,7 @@ export default function Breadcrumb() {
<> <>
{notePath.slice(0, INITIAL_ITEMS).map((item, index) => ( {notePath.slice(0, INITIAL_ITEMS).map((item, index) => (
<Fragment key={item}> <Fragment key={item}>
{index === 0 <BreadcrumbItem index={index} notePath={item} notePathLength={notePath.length} noteContext={noteContext} />
? <BreadcrumbRoot noteContext={noteContext} />
: <BreadcrumbItem notePath={item} />
}
<BreadcrumbSeparator notePath={item} activeNotePath={notePath[index + 1]} noteContext={noteContext} /> <BreadcrumbSeparator notePath={item} activeNotePath={notePath[index + 1]} noteContext={noteContext} />
</Fragment> </Fragment>
))} ))}
@ -38,7 +37,7 @@ export default function Breadcrumb() {
{notePath.slice(-FINAL_ITEMS).map((item, index) => ( {notePath.slice(-FINAL_ITEMS).map((item, index) => (
<Fragment key={item}> <Fragment key={item}>
<BreadcrumbSeparator notePath={notePath[notePath.length - FINAL_ITEMS - (1 - index)]} activeNotePath={item} noteContext={noteContext} /> <BreadcrumbSeparator notePath={notePath[notePath.length - FINAL_ITEMS - (1 - index)]} activeNotePath={item} noteContext={noteContext} />
<BreadcrumbItem notePath={item} /> <BreadcrumbItem index={notePath.length - FINAL_ITEMS + index} notePath={item} notePathLength={notePath.length} noteContext={noteContext} />
</Fragment> </Fragment>
))} ))}
</> </>
@ -47,7 +46,7 @@ export default function Breadcrumb() {
<Fragment key={item}> <Fragment key={item}>
{index === 0 {index === 0
? <BreadcrumbRoot noteContext={noteContext} /> ? <BreadcrumbRoot noteContext={noteContext} />
: <BreadcrumbItem notePath={item} /> : <BreadcrumbItem index={index} notePath={item} notePathLength={notePath.length} noteContext={noteContext} />
} }
{(index < notePath.length - 1 || note?.hasChildren()) && {(index < notePath.length - 1 || note?.hasChildren()) &&
<BreadcrumbSeparator notePath={item} activeNotePath={notePath[index + 1]} noteContext={noteContext} />} <BreadcrumbSeparator notePath={item} activeNotePath={notePath[index + 1]} noteContext={noteContext} />}
@ -76,15 +75,56 @@ function BreadcrumbRoot({ noteContext }: { noteContext: NoteContext | undefined
); );
} }
function BreadcrumbItem({ notePath }: { notePath: string }) { function BreadcrumbLink({ notePath }: { notePath: string }) {
return ( return (
<NoteLink <NoteLink
notePath={notePath} notePath={notePath}
noPreview
/> />
); );
} }
function BreadcrumbLastItem({ notePath }: { notePath: string }) {
const noteId = notePath.split("/").at(-1);
const [ note ] = useState(() => froca.getNoteFromCache(noteId!));
const [ isEditing, setIsEditing ] = useState(false);
const title = useNoteProperty(note, "title");
if (!note) return null;
if (!isEditing) {
return (
<a
href="#"
className="breadcrumb-last-item tn-link"
onClick={(e) => {
e.preventDefault();
setIsEditing(true);
}}
>{title}</a>
);
}
return (
<TitleEditor
currentValue={title}
save={(newTitle) => { return server.put(`notes/${noteId}/title`, { title: newTitle.trim() }); }}
dismiss={() => setIsEditing(false)}
/>
);
}
function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { index: number, notePathLength: number, notePath: string, noteContext: NoteContext | undefined }) {
if (index === 0) {
return <BreadcrumbRoot noteContext={noteContext} />;
}
if (index === notePathLength - 1) {
return <BreadcrumbLastItem notePath={notePath} />;
}
return <BreadcrumbLink notePath={notePath} />;
}
function BreadcrumbSeparator({ notePath, noteContext, activeNotePath }: { notePath: string, activeNotePath: string, noteContext: NoteContext | undefined }) { function BreadcrumbSeparator({ notePath, noteContext, activeNotePath }: { notePath: string, activeNotePath: string, noteContext: NoteContext | undefined }) {
return ( return (
<Dropdown <Dropdown

View File

@ -1,23 +1,71 @@
.component.breadcrumb-badges { .component.breadcrumb-badges {
contain: none;
}
.breadcrumb-badges {
display: flex; display: flex;
gap: 5px; gap: 5px;
contain: none; min-width: 0;
flex-shrink: 1;
overflow: hidden;
--badge-radius: 12px;
.breadcrumb-badge { .breadcrumb-badge {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 2px 6px; padding: 2px 6px;
border-radius: 12px; border-radius: var(--badge-radius);
font-size: 0.75em; font-size: 0.75em;
background-color: var(--badge-background-color); background-color: var(--color, transparent);
color: var(--badge-text-color); color: white;
min-width: 0;
flex-shrink: 1;
&.clickable { &.clickable {
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background-color: var(--badge-background-hover-color); background-color: color-mix(in srgb, var(--color, --badge-background-color) 80%, black);
} }
} }
&.temporarily-editable-badge { --color: #4fa52b; }
&.read-only-badge { --color: #e33f3b; }
&.share-badge { --color: #3b82f6; }
&.backlinks-badge { color: var(--badge-text-color); }
a {
color: inherit;
text-decoration: none;
}
> * {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.dropdown {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: var(--badge-radius);
&.dropdown-backlinks-badge .dropdown-menu {
min-width: 500px;
}
.breadcrumb-badge {
border-radius: 0;
}
.btn {
border: 0;
margin: 0;
padding: 0;
}
} }
} }

View File

@ -1,17 +1,22 @@
import "./BreadcrumbBadges.css"; import "./BreadcrumbBadges.css";
import { ComponentChildren } from "preact"; import clsx from "clsx";
import { useIsNoteReadOnly, useNoteContext } from "./react/hooks"; import { ComponentChildren, MouseEventHandler } from "preact";
import { useRef } from "preact/hooks";
import { t } from "../services/i18n";
import { BacklinksList, useBacklinkCount } from "./FloatingButtonsDefinitions";
import Dropdown, { DropdownProps } from "./react/Dropdown";
import { useIsNoteReadOnly, useNoteContext, useStaticTooltip } from "./react/hooks";
import Icon from "./react/Icon"; import Icon from "./react/Icon";
import { useShareInfo } from "./shared_info"; import { useShareInfo } from "./shared_info";
import clsx from "clsx";
import { t } from "../services/i18n";
export default function BreadcrumbBadges() { export default function BreadcrumbBadges() {
return ( return (
<div className="breadcrumb-badges"> <div className="breadcrumb-badges">
<ReadOnlyBadge /> <ReadOnlyBadge />
<ShareBadge /> <ShareBadge />
<BacklinksBadge />
</div> </div>
); );
} }
@ -20,37 +25,113 @@ function ReadOnlyBadge() {
const { note, noteContext } = useNoteContext(); const { note, noteContext } = useNoteContext();
const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext); const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext);
const isExplicitReadOnly = note?.isLabelTruthy("readOnly"); const isExplicitReadOnly = note?.isLabelTruthy("readOnly");
const isTemporarilyEditable = noteContext?.viewScope?.readOnlyTemporarilyDisabled;
return (isReadOnly && if (isTemporarilyEditable) {
<Badge return <Badge
icon="bx bx-lock" icon="bx bx-lock-open-alt"
onClick={() => enableEditing()}> text={t("breadcrumb_badges.read_only_temporarily_disabled")}
{isExplicitReadOnly ? t("breadcrumb_badges.read_only_explicit") : t("breadcrumb_badges.read_only_auto")} tooltip={t("breadcrumb_badges.read_only_temporarily_disabled_description")}
</Badge> className="temporarily-editable-badge"
); onClick={() => enableEditing(false)}
/>;
} else if (isReadOnly) {
return <Badge
icon="bx bx-lock-alt"
text={isExplicitReadOnly ? t("breadcrumb_badges.read_only_explicit") : t("breadcrumb_badges.read_only_auto")}
tooltip={isExplicitReadOnly ? t("breadcrumb_badges.read_only_explicit_description") : t("breadcrumb_badges.read_only_auto_description")}
className="read-only-badge"
onClick={() => enableEditing()}
/>;
}
} }
function ShareBadge() { function ShareBadge() {
const { note } = useNoteContext(); const { note } = useNoteContext();
const { isSharedExternally, link } = useShareInfo(note); const { isSharedExternally, link, linkHref } = useShareInfo(note);
return (link && return (link &&
<Badge <Badge
icon={isSharedExternally ? "bx bx-world" : "bx bx-link"} icon={isSharedExternally ? "bx bx-world" : "bx bx-link"}
> text={isSharedExternally ? t("breadcrumb_badges.shared_publicly") : t("breadcrumb_badges.shared_locally")}
{isSharedExternally ? t("breadcrumb_badges.shared_publicly") : t("breadcrumb_badges.shared_locally")} tooltip={isSharedExternally ?
</Badge> t("breadcrumb_badges.shared_publicly_description", { link }) :
t("breadcrumb_badges.shared_locally_description", { link })
}
className="share-badge"
href={linkHref}
/>
); );
} }
function Badge({ icon, children, onClick }: { icon: string, children: ComponentChildren, onClick?: () => void }) { function BacklinksBadge() {
const { note, viewScope } = useNoteContext();
const count = useBacklinkCount(note, viewScope?.viewMode === "default");
return (note && count > 0 &&
<BadgeWithDropdown
className="backlinks-badge backlinks-widget"
icon="bx bx-revision"
text={t("breadcrumb_badges.backlinks", { count })}
tooltip={t("breadcrumb_badges.backlinks_description", { count })}
dropdownOptions={{
dropdownContainerClassName: "backlinks-items"
}}
>
<BacklinksList note={note} />
</BadgeWithDropdown>
);
}
interface BadgeProps {
text: string;
icon?: string;
className: string;
tooltip?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
href?: string;
}
function Badge({ icon, className, text, tooltip, onClick, href }: BadgeProps) {
const containerRef = useRef<HTMLDivElement>(null);
useStaticTooltip(containerRef, {
placement: "bottom",
fallbackPlacements: [ "bottom" ],
animation: false,
html: true,
title: tooltip
});
const content = <>
{icon && <><Icon icon={icon} />&nbsp;</>}
<span class="text">{text}</span>
</>;
return ( return (
<div <div
className={clsx("breadcrumb-badge", { "clickable": !!onClick })} ref={containerRef}
className={clsx("breadcrumb-badge", className, { "clickable": !!onClick })}
onClick={onClick} onClick={onClick}
> >
<Icon icon={icon} />&nbsp; {href ? <a href={href}>{content}</a> : <span>{content}</span>}
{children}
</div> </div>
); );
} }
function BadgeWithDropdown({ children, tooltip, className, dropdownOptions, ...props }: BadgeProps & {
children: ComponentChildren,
dropdownOptions?: Partial<DropdownProps>
}) {
return (
<Dropdown
className={`dropdown-${className}`}
text={<Badge className={className} {...props} />}
noDropdownListStyle
noSelectButtonStyle
hideToggleArrow
title={tooltip}
titlePosition="bottom"
dropdownOptions={{ popperConfig: { placement: "bottom", strategy: "fixed" } }}
{...dropdownOptions}
>{children}</Dropdown>
);
}

View File

@ -5,7 +5,7 @@ import NoteContext from "../components/note_context";
import FNote from "../entities/fnote"; import FNote from "../entities/fnote";
import ActionButton, { ActionButtonProps } from "./react/ActionButton"; import ActionButton, { ActionButtonProps } from "./react/ActionButton";
import { useIsNoteReadOnly, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks"; import { useIsNoteReadOnly, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils"; import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils";
import server from "../services/server"; import server from "../services/server";
import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons"; import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
@ -20,6 +20,7 @@ import RawHtml from "./react/RawHtml";
import { ViewTypeOptions } from "./collections/interface"; import { ViewTypeOptions } from "./collections/interface";
import attributes from "../services/attributes"; import attributes from "../services/attributes";
import LoadResults from "../services/load_results"; import LoadResults from "../services/load_results";
import { isExperimentalFeatureEnabled } from "../services/experimental_features";
export interface FloatingButtonContext { export interface FloatingButtonContext {
parentComponent: Component; parentComponent: Component;
@ -76,6 +77,8 @@ export const POPUP_HIDDEN_FLOATING_BUTTONS: FloatingButtonsList = [
ToggleReadOnlyButton ToggleReadOnlyButton
]; ];
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) { function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode; const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode;
return isEnabled && <FloatingButton return isEnabled && <FloatingButton
@ -308,22 +311,9 @@ function InAppHelpButton({ note }: FloatingButtonContext) {
} }
function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) { function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
let [ backlinkCount, setBacklinkCount ] = useState(0); const [ popupOpen, setPopupOpen ] = useState(false);
let [ popupOpen, setPopupOpen ] = useState(false);
const backlinksContainerRef = useRef<HTMLDivElement>(null); const backlinksContainerRef = useRef<HTMLDivElement>(null);
const backlinkCount = useBacklinkCount(note, isDefaultViewMode);
function refresh() {
if (!isDefaultViewMode) return;
server.get<BacklinkCountResponse>(`note-map/${note.noteId}/backlink-count`).then(resp => {
setBacklinkCount(resp.count);
});
}
useEffect(() => refresh(), [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (needsRefresh(note, loadResults)) refresh();
});
// Determine the max height of the container. // Determine the max height of the container.
const { windowHeight } = useWindowSize(); const { windowHeight } = useWindowSize();
@ -336,7 +326,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
} }
}, [ popupOpen, windowHeight ]); }, [ popupOpen, windowHeight ]);
const isEnabled = isDefaultViewMode && backlinkCount > 0; const isEnabled = !isNewLayout && isDefaultViewMode && backlinkCount > 0;
return (isEnabled && return (isEnabled &&
<div className="backlinks-widget has-overflow"> <div className="backlinks-widget has-overflow">
<div <div
@ -355,15 +345,34 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
); );
} }
function BacklinksList({ note }: { note: FNote }) { export function useBacklinkCount(note: FNote | null | undefined, isDefaultViewMode: boolean) {
const [ backlinkCount, setBacklinkCount ] = useState(0);
const refresh = useCallback(() => {
if (!note || !isDefaultViewMode) return;
server.get<BacklinkCountResponse>(`note-map/${note.noteId}/backlink-count`).then(resp => {
setBacklinkCount(resp.count);
});
}, [ isDefaultViewMode, note ]);
useEffect(() => refresh(), [ refresh ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (note && needsRefresh(note, loadResults)) refresh();
});
return backlinkCount;
}
export function BacklinksList({ note }: { note: FNote }) {
const [ backlinks, setBacklinks ] = useState<BacklinksResponse>([]); const [ backlinks, setBacklinks ] = useState<BacklinksResponse>([]);
function refresh() { function refresh() {
server.get<BacklinksResponse>(`note-map/${note.noteId}/backlinks`).then(async (backlinks) => { server.get<BacklinksResponse>(`note-map/${note.noteId}/backlinks`).then(async (backlinks) => {
// prefetch all // prefetch all
const noteIds = backlinks const noteIds = backlinks
.filter(bl => "noteId" in bl) .filter(bl => "noteId" in bl)
.map((bl) => bl.noteId); .map((bl) => bl.noteId);
await froca.getNotes(noteIds); await froca.getNotes(noteIds);
setBacklinks(backlinks); setBacklinks(backlinks);
}); });

View File

@ -1,23 +1,60 @@
import { t } from "../services/i18n"; import { type ComponentChild } from "preact";
import { formatDateTime } from "../utils/formatters"; import { formatDateTime } from "../utils/formatters";
import { useNoteContext } from "./react/hooks"; import { useNoteContext, useStaticTooltip } from "./react/hooks";
import { joinElements } from "./react/react_utils"; import { joinElements } from "./react/react_utils";
import { useNoteMetadata } from "./ribbon/NoteInfoTab"; import { useNoteMetadata } from "./ribbon/NoteInfoTab";
import { Trans } from "react-i18next";
import { useRef } from "preact/hooks";
export default function NoteTitleDetails() { export default function NoteTitleDetails() {
const { note } = useNoteContext(); const { note, noteContext } = useNoteContext();
const { metadata } = useNoteMetadata(note); const { metadata } = useNoteMetadata(note);
const isHiddenNote = note?.noteId.startsWith("_");
const isDefaultView = noteContext?.viewScope?.viewMode === "default";
const items: ComponentChild[] = [
(isDefaultView && !isHiddenNote && metadata?.dateCreated &&
<TextWithValue
i18nKey="note_title.created_on"
value={formatDateTime(metadata.dateCreated, "medium", "none")}
valueTooltip={formatDateTime(metadata.dateCreated, "full", "long")}
/>),
(isDefaultView && !isHiddenNote && metadata?.dateModified &&
<TextWithValue
i18nKey="note_title.last_modified"
value={formatDateTime(metadata.dateModified, "medium", "none")}
valueTooltip={formatDateTime(metadata.dateModified, "full", "long")}
/>)
].filter(item => !!item);
return ( return (
<div className="title-details"> <div className="title-details">
{joinElements([ {joinElements(items, " • ")}
metadata?.dateCreated && <li>
{t("note_title.created_on", { date: formatDateTime(metadata.dateCreated, "medium", "none")} )}
</li>,
metadata?.dateModified && <li>
{t("note_title.last_modified", { date: formatDateTime(metadata.dateModified, "medium", "none")} )}
</li>
], " • ")}
</div> </div>
); );
} }
function TextWithValue({ i18nKey, value, valueTooltip }: {
i18nKey: string;
value: string;
valueTooltip: string;
}) {
const listItemRef = useRef<HTMLLIElement>(null);
useStaticTooltip(listItemRef, {
selector: "span.value",
title: valueTooltip,
popperConfig: { placement: "bottom" }
});
return (
<li ref={listItemRef}>
<Trans
i18nKey={i18nKey}
components={{
Value: <span className="value">{value}</span> as React.ReactElement
}}
/>
</li>
);
}

View File

@ -12,6 +12,7 @@ import shortcutService from "../../services/shortcuts.js";
import appContext from "../../components/app_context.js"; import appContext from "../../components/app_context.js";
import type { Attribute } from "../../services/attribute_parser.js"; import type { Attribute } from "../../services/attribute_parser.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js"; import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
const TPL = /*html*/` const TPL = /*html*/`
<div class="attr-detail tn-tool-dialog"> <div class="attr-detail tn-tool-dialog">
@ -309,6 +310,8 @@ interface SearchRelatedResponse {
count: number; count: number;
} }
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export default class AttributeDetailWidget extends NoteContextAwareWidget { export default class AttributeDetailWidget extends NoteContextAwareWidget {
private $title!: JQuery<HTMLElement>; private $title!: JQuery<HTMLElement>;
private $inputName!: JQuery<HTMLElement>; private $inputName!: JQuery<HTMLElement>;
@ -579,6 +582,13 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
.css("top", y - offset.top + 70) .css("top", y - offset.top + 70)
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000); .css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
if (isNewLayout) {
this.$widget
.css("top", "unset")
.css("bottom", 70)
.css("max-height", "80vh");
}
if (focus === "name") { if (focus === "name") {
this.$inputName.trigger("focus").trigger("select"); this.$inputName.trigger("focus").trigger("select");
} }

View File

@ -243,7 +243,7 @@ function AddNewColumn({ api, isInRelationMode }: { api: BoardApi, isInRelationMo
export function TitleEditor({ currentValue, placeholder, save, dismiss, mode, isNewItem }: { export function TitleEditor({ currentValue, placeholder, save, dismiss, mode, isNewItem }: {
currentValue?: string; currentValue?: string;
placeholder?: string; placeholder?: string;
save: (newValue: string) => void; save: (newValue: string) => void | Promise<void>;
dismiss: () => void; dismiss: () => void;
isNewItem?: boolean; isNewItem?: boolean;
mode?: "normal" | "multiline" | "relation"; mode?: "normal" | "multiline" | "relation";

View File

@ -31,6 +31,7 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
align-items: center; align-items: center;
margin-block: 0;
} }
.modal.popup-editor-dialog .modal-header .note-title-widget { .modal.popup-editor-dialog .modal-header .note-title-widget {

View File

@ -1,26 +1,32 @@
import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import Modal from "../react/Modal";
import "./PopupEditor.css"; import "./PopupEditor.css";
import { useNoteContext, useNoteLabel, useTriliumEvent } from "../react/hooks";
import NoteTitleWidget from "../note_title";
import NoteIcon from "../note_icon";
import NoteContext from "../../components/note_context";
import { NoteContextContext, ParentComponent } from "../react/react_utils";
import NoteDetail from "../NoteDetail";
import { ComponentChildren } from "preact"; import { ComponentChildren } from "preact";
import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import appContext from "../../components/app_context";
import NoteContext from "../../components/note_context";
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import tree from "../../services/tree";
import utils from "../../services/utils";
import NoteList from "../collections/NoteList"; import NoteList from "../collections/NoteList";
import StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter";
import FormattingToolbar from "../ribbon/FormattingToolbar";
import PromotedAttributes from "../PromotedAttributes";
import FloatingButtons from "../FloatingButtons"; import FloatingButtons from "../FloatingButtons";
import { DESKTOP_FLOATING_BUTTONS, MOBILE_FLOATING_BUTTONS, POPUP_HIDDEN_FLOATING_BUTTONS } from "../FloatingButtonsDefinitions"; import { DESKTOP_FLOATING_BUTTONS, MOBILE_FLOATING_BUTTONS, POPUP_HIDDEN_FLOATING_BUTTONS } from "../FloatingButtonsDefinitions";
import utils from "../../services/utils"; import NoteIcon from "../note_icon";
import tree from "../../services/tree"; import NoteTitleWidget from "../note_title";
import froca from "../../services/froca"; import NoteDetail from "../NoteDetail";
import PromotedAttributes from "../PromotedAttributes";
import { useNoteContext, useNoteLabel, useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import { NoteContextContext, ParentComponent } from "../react/react_utils";
import ReadOnlyNoteInfoBar from "../ReadOnlyNoteInfoBar"; import ReadOnlyNoteInfoBar from "../ReadOnlyNoteInfoBar";
import StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter";
import FormattingToolbar from "../ribbon/FormattingToolbar";
import MobileEditorToolbar from "../type_widgets/text/mobile_editor_toolbar"; import MobileEditorToolbar from "../type_widgets/text/mobile_editor_toolbar";
import { t } from "../../services/i18n"; import BreadcrumbBadges from "../BreadcrumbBadges";
import appContext from "../../components/app_context"; import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export default function PopupEditor() { export default function PopupEditor() {
const [ shown, setShown ] = useState(false); const [ shown, setShown ] = useState(false);
@ -61,7 +67,10 @@ export default function PopupEditor() {
<NoteContextContext.Provider value={noteContext}> <NoteContextContext.Provider value={noteContext}>
<DialogWrapper> <DialogWrapper>
<Modal <Modal
title={<TitleRow />} title={<>
<TitleRow />
{isNewLayout && <BreadcrumbBadges />}
</>}
customTitleBarButtons={[{ customTitleBarButtons={[{
iconClassName: "bx-expand-alt", iconClassName: "bx-expand-alt",
title: t("popup-editor.maximize"), title: t("popup-editor.maximize"),
@ -75,19 +84,17 @@ export default function PopupEditor() {
className="popup-editor-dialog" className="popup-editor-dialog"
size="lg" size="lg"
show={shown} show={shown}
onShown={() => { onShown={() => parentComponent?.handleEvent("focusOnDetail", { ntxId: noteContext.ntxId })}
parentComponent?.handleEvent("focusOnDetail", { ntxId: noteContext.ntxId });
}}
onHidden={() => setShown(false)} onHidden={() => setShown(false)}
keepInDom // needed for faster loading keepInDom // needed for faster loading
noFocus // automatic focus breaks block popup noFocus // automatic focus breaks block popup
> >
<ReadOnlyNoteInfoBar /> {!isNewLayout && <ReadOnlyNoteInfoBar />}
<PromotedAttributes /> <PromotedAttributes />
{isMobile {isMobile
? <MobileEditorToolbar inPopupEditor /> ? <MobileEditorToolbar inPopupEditor />
: <StandaloneRibbonAdapter component={FormattingToolbar} />} : <StandaloneRibbonAdapter component={FormattingToolbar} />}
<FloatingButtons items={items} /> <FloatingButtons items={items} />
<NoteDetail /> <NoteDetail />
@ -95,7 +102,7 @@ export default function PopupEditor() {
</Modal> </Modal>
</DialogWrapper> </DialogWrapper>
</NoteContextContext.Provider> </NoteContextContext.Provider>
) );
} }
export function DialogWrapper({ children }: { children: ComponentChildren }) { export function DialogWrapper({ children }: { children: ComponentChildren }) {
@ -107,7 +114,7 @@ export function DialogWrapper({ children }: { children: ComponentChildren }) {
<div ref={wrapperRef} class={`quick-edit-dialog-wrapper ${note?.getColorClass() ?? ""}`}> <div ref={wrapperRef} class={`quick-edit-dialog-wrapper ${note?.getColorClass() ?? ""}`}>
{children} {children}
</div> </div>
) );
} }
export function TitleRow() { export function TitleRow() {
@ -116,5 +123,5 @@ export function TitleRow() {
<NoteIcon /> <NoteIcon />
<NoteTitleWidget /> <NoteTitleWidget />
</div> </div>
) );
} }

View File

@ -29,30 +29,73 @@ body.desktop .note-title-widget input.note-title {
font-size: 180%; font-size: 180%;
} }
body.experimental-feature-new-layout .title-row, body.experimental-feature-new-layout {
body.experimental-feature-new-layout .title-details { .title-row,
max-width: var(--max-content-width); .title-details {
} max-width: var(--max-content-width);
padding: 0;
padding-inline-start: 24px;
}
body.experimental-feature-new-layout .title-row { .title-row {
margin-top: 2em; margin-left: 12px;
margin-left: 12px;
}
body.experimental-feature-new-layout .title-details { .note-icon-widget {
margin-top: 0; padding: 0;
contain: none; width: 41px;
padding: 0; }
padding-inline-start: 24px; }
opacity: 0.85;
display: flex;
gap: 0.25em;
margin: 0;
list-style-type: none;
margin-bottom: 2em;
}
body.experimental-feature-new-layout.prefers-centered-content .title-row, .note-split.type-code:not(.mime-text-x-sqlite) .title-row,
body.experimental-feature-new-layout.prefers-centered-content .title-details { .note-split.type-code:not(.mime-text-x-sqlite) .title-details {
margin-inline: auto; background-color: var(--main-background-color);
}
.title-details {
margin-top: 0;
contain: none;
display: flex;
gap: 0.25em;
margin: 0;
list-style-type: none;
span.value {
font-weight: 500;
}
}
.note-split.view-mode-default {
.title-row {
padding-top: 2em;
box-sizing: content-box;
}
.title-details {
padding-bottom: 2em;
}
}
.scrolling-container:has(> :is(.note-detail.full-height, .note-list-widget.full-height)) {
.title-row,
.title-details {
width: 100%;
max-width: unset;
padding-inline-start: 15px;
}
.title-row {
margin-top: 0;
}
.title-details {
margin-bottom: 0.2em;
opacity: 0.65;
font-size: 0.8em;
}
}
&.prefers-centered-content .title-row,
&.prefers-centered-content .title-details {
margin-inline: auto;
}
} }

View File

@ -62,6 +62,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
this.$widget.addClass(utils.getNoteTypeClass(note.type)); this.$widget.addClass(utils.getNoteTypeClass(note.type));
this.$widget.addClass(utils.getMimeTypeClass(note.mime)); this.$widget.addClass(utils.getMimeTypeClass(note.mime));
this.$widget.addClass(`view-mode-${this.noteContext?.viewScope?.viewMode ?? "default"}`);
this.$widget.toggleClass(["bgfx", "options"], note.isOptions()); this.$widget.toggleClass(["bgfx", "options"], note.isOptions());
this.$widget.toggleClass("protected", note.isProtected); this.$widget.toggleClass("protected", note.isProtected);

View File

@ -117,8 +117,8 @@ export default function Dropdown({ id, className, buttonClassName, isStatic, chi
aria-expanded="false" aria-expanded="false"
id={id ?? ariaId} id={id ?? ariaId}
disabled={disabled} disabled={disabled}
onMouseOver={() => showTooltip()} onMouseEnter={showTooltip}
onMouseLeave={() => hideTooltip()} onMouseLeave={hideTooltip}
{...buttonProps} {...buttonProps}
> >
{text} {text}

View File

@ -161,11 +161,16 @@ export function FormDropdownDivider() {
return <div className="dropdown-divider" />; return <div className="dropdown-divider" />;
} }
export function FormDropdownSubmenu({ icon, title, children }: { icon: string, title: ComponentChildren, children: ComponentChildren }) { export function FormDropdownSubmenu({ icon, title, children, dropStart }: {
icon: string,
title: ComponentChildren,
children: ComponentChildren,
dropStart?: boolean
}) {
const [ openOnMobile, setOpenOnMobile ] = useState(false); const [ openOnMobile, setOpenOnMobile ] = useState(false);
return ( return (
<li className={`dropdown-item dropdown-submenu ${openOnMobile ? "submenu-open" : ""}`}> <li className={clsx("dropdown-item dropdown-submenu", { "submenu-open": openOnMobile, "dropstart": dropStart })}>
<span <span
className="dropdown-toggle" className="dropdown-toggle"
onClick={(e) => { onClick={(e) => {
@ -184,5 +189,5 @@ export function FormDropdownSubmenu({ icon, title, children }: { icon: string, t
{children} {children}
</ul> </ul>
</li> </li>
) );
} }

View File

@ -201,7 +201,7 @@ export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean):
return [ return [
(value === "true"), (value === "true"),
(newValue) => setValue(newValue ? "true" : "false") (newValue) => setValue(newValue ? "true" : "false")
] ];
} }
/** /**
@ -217,17 +217,18 @@ export function useTriliumOptionInt(name: OptionNames): [number, (newValue: numb
return [ return [
(parseInt(value, 10)), (parseInt(value, 10)),
(newValue) => setValue(newValue) (newValue) => setValue(newValue)
] ];
} }
/** /**
* Similar to {@link useTriliumOption}, but the object value is parsed to and from a JSON instead of a string. * Similar to {@link useTriliumOption}, but the object value is parsed to and from a JSON instead of a string.
* *
* @param name the name of the option to listen for. * @param name the name of the option to listen for.
* @param needsRefresh whether to reload the frontend whenever the value is changed.
* @returns an array where the first value is the current option value and the second value is the setter. * @returns an array where the first value is the current option value and the second value is the setter.
*/ */
export function useTriliumOptionJson<T>(name: OptionNames): [ T, (newValue: T) => Promise<void> ] { export function useTriliumOptionJson<T>(name: OptionNames, needsRefresh?: boolean): [ T, (newValue: T) => Promise<void> ] {
const [ value, setValue ] = useTriliumOption(name); const [ value, setValue ] = useTriliumOption(name, needsRefresh);
useDebugValue(name); useDebugValue(name);
return [ return [
(JSON.parse(value) as T), (JSON.parse(value) as T),
@ -845,9 +846,9 @@ export function useGlobalShortcut(keyboardShortcut: string | null | undefined, h
export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: NoteContext | undefined) { 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(() => { const enableEditing = useCallback((enabled = true) => {
if (noteContext?.viewScope) { if (noteContext?.viewScope) {
noteContext.viewScope.readOnlyTemporarilyDisabled = true; noteContext.viewScope.readOnlyTemporarilyDisabled = enabled;
appContext.triggerEvent("readOnlyTemporarilyDisabled", {noteContext}); appContext.triggerEvent("readOnlyTemporarilyDisabled", {noteContext});
} }
}, [noteContext]); }, [noteContext]);
@ -862,7 +863,7 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
useTriliumEvent("readOnlyTemporarilyDisabled", ({noteContext: eventNoteContext}) => { useTriliumEvent("readOnlyTemporarilyDisabled", ({noteContext: eventNoteContext}) => {
if (noteContext?.ntxId === eventNoteContext.ntxId) { if (noteContext?.ntxId === eventNoteContext.ntxId) {
setIsReadOnly(false); setIsReadOnly(!noteContext.viewScope?.readOnlyTemporarilyDisabled);
} }
}); });

View File

@ -13,7 +13,7 @@ import { isElectron as getIsElectron, isMac as getIsMac } from "../../services/u
import ws from "../../services/ws"; import ws from "../../services/ws";
import ActionButton from "../react/ActionButton"; import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown"; import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormListHeader, FormListItem } from "../react/FormList"; import { FormDropdownDivider, FormDropdownSubmenu, FormListItem } from "../react/FormList";
import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteProperty, useTriliumOption } from "../react/hooks"; import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteProperty, useTriliumOption } from "../react/hooks";
import { ParentComponent } from "../react/react_utils"; import { ParentComponent } from "../react/react_utils";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features"; import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
@ -98,7 +98,7 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
} }
<FormDropdownDivider /> <FormDropdownDivider />
<CommandItem command="showRevisions" icon="bx bx-history" text={t("revisions_button.note_revisions")} /> <CommandItem command="showRevisions" icon="bx bx-history" text={t("note_actions.view_revisions")} />
<CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptionsOrHelp} text={t("note_actions.save_revision")} /> <CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptionsOrHelp} text={t("note_actions.save_revision")} />
<CommandItem icon="bx bx-trash destructive-action-icon" text={t("note_actions.delete_note")} destructive <CommandItem icon="bx bx-trash destructive-action-icon" text={t("note_actions.delete_note")} destructive
disabled={isInOptionsOrHelp} disabled={isInOptionsOrHelp}
@ -114,23 +114,22 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
function DevelopmentActions({ note, noteContext }: { note: FNote, noteContext?: NoteContext }) { function DevelopmentActions({ note, noteContext }: { note: FNote, noteContext?: NoteContext }) {
return ( return (
<> <FormDropdownSubmenu title="Development Actions" icon="bx bx-wrench" dropStart>
<FormListHeader text="Development-only Actions" />
<FormListItem <FormListItem
icon="bx bx-printer" icon="bx bx-printer"
onClick={() => window.open(`/?print=#root/${note.noteId}`, "_blank")} onClick={() => window.open(`/?print=#root/${note.noteId}`, "_blank")}
>Open print page</FormListItem> >Open print page</FormListItem>
{note.type === "text" && ( <FormListItem
<FormListItem icon="bx bx-error"
icon="bx bx-error" disabled={note.type !== "text"}
onClick={() => { onClick={() => {
noteContext?.getTextEditor(editor => { noteContext?.getTextEditor(editor => {
editor.editing.view.change(() => { editor.editing.view.change(() => {
throw new Error("Editor crashed."); throw new Error("Editor crashed.");
});
}); });
}}>Crash editor</FormListItem>)} });
</> }}>Crash editor</FormListItem>
</FormDropdownSubmenu>
); );
} }

View File

@ -22,7 +22,7 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
{ {
title: t("classic_editor_toolbar.title"), title: t("classic_editor_toolbar.title"),
icon: "bx bx-text", icon: "bx bx-text",
show: async ({ note, noteContext }) => note?.type === "text" show: async ({ note, noteContext }) => note?.type === "text" && noteContext?.viewScope?.viewMode === "default"
&& options.get("textNoteEditorType") === "ckeditor-classic" && options.get("textNoteEditorType") === "ckeditor-classic"
&& !(await noteContext?.isReadOnly()), && !(await noteContext?.isReadOnly()),
toggleCommand: "toggleRibbonTabClassicEditor", toggleCommand: "toggleRibbonTabClassicEditor",

View File

@ -26,6 +26,7 @@ export default function SharedInfo() {
export function useShareInfo(note: FNote | null | undefined) { export function useShareInfo(note: FNote | null | undefined) {
const [ link, setLink ] = useState<string>(); const [ link, setLink ] = useState<string>();
const [ linkHref, setLinkHref ] = useState<string>();
const [ syncServerHost ] = useTriliumOption("syncServerHost"); const [ syncServerHost ] = useTriliumOption("syncServerHost");
function refresh() { function refresh() {
@ -52,9 +53,10 @@ export function useShareInfo(note: FNote | null | undefined) {
} }
setLink(`<a href="${link}" class="external tn-link">${link}</a>`); setLink(`<a href="${link}" class="external tn-link">${link}</a>`);
setLinkHref(link);
} }
useEffect(refresh, [ note ]); useEffect(refresh, [ note, syncServerHost ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => { useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows().find((attr) => attr.name?.startsWith("_share") && attributes.isAffecting(attr, note))) { if (loadResults.getAttributeRows().find((attr) => attr.name?.startsWith("_share") && attributes.isAffecting(attr, note))) {
refresh(); refresh();
@ -63,7 +65,7 @@ export function useShareInfo(note: FNote | null | undefined) {
} }
}); });
return { link, isSharedExternally: !!syncServerHost }; return { link, linkHref, isSharedExternally: !!syncServerHost };
} }
function getShareId(note: FNote) { function getShareId(note: FNote) {

View File

@ -158,7 +158,7 @@ function ExistingAnonymizedDatabases({ databases }: { databases: AnonymizedDbRes
))} ))}
</tbody> </tbody>
</table> </table>
) );
} }
function VacuumDatabaseOptions() { function VacuumDatabaseOptions() {
@ -175,11 +175,11 @@ function VacuumDatabaseOptions() {
}} }}
/> />
</OptionsSection> </OptionsSection>
) );
} }
function ExperimentalOptions() { function ExperimentalOptions() {
const [ enabledExperimentalFeatures, setEnabledExperimentalFeatures ] = useTriliumOptionJson<string[]>("experimentalFeatures"); const [ enabledExperimentalFeatures, setEnabledExperimentalFeatures ] = useTriliumOptionJson<string[]>("experimentalFeatures", true);
return ( return (
<OptionsSection title={t("experimental_features.title")}> <OptionsSection title={t("experimental_features.title")}>