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")
.cssBlock(".breadcrumb-row > * { margin: 5px; }")
.child(<Breadcrumb />)
.child(<BreadcrumbBadges />)
.optChild(isNewLayout, <BreadcrumbBadges />)
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
.child(<MovePaneButton direction="left" />)
.child(<MovePaneButton direction="right" />)

View File

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

View File

@ -1321,6 +1321,11 @@ body.desktop li.dropdown-submenu:hover > ul.dropdown-menu {
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 {
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
* component of the color represented in the CIELAB color space, will be
* constrained to a certain percentage defined below.
*
*
* 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. */
/* The maximum perceptual lightness for the custom color in the light theme (%): */
--tree-item-light-theme-max-color-lightness: 60;
/* The minimum perceptual lightness for the custom color in the dark theme (%): */
--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-end-padding: 22px;
--menu-item-vertical-padding: 2px;
padding-top: var(--menu-item-vertical-padding) !important;
padding-bottom: var(--menu-item-vertical-padding) !important;
padding-inline-start: var(--menu-item-start-padding) !important;
@ -176,6 +176,11 @@ body.desktop .dropdown-submenu .dropdown-menu {
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 {
outline: 2px solid var(--input-focus-outline-color) !important;
background-color: transparent;
@ -249,7 +254,7 @@ html body .dropdown-item[disabled] {
}
/* Menu item arrow */
.dropdown-menu .dropdown-toggle::after {
.dropdown-submenu:not(.dropstart) .dropdown-toggle::after {
content: "\ed3b" !important;
position: absolute;
display: flex !important;
@ -265,6 +270,22 @@ html body .dropdown-item[disabled] {
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 {
content: "\ea4d" !important;
}
@ -339,7 +360,7 @@ body.mobile .dropdown-menu {
font-size: 1em !important;
backdrop-filter: var(--dropdown-backdrop-filter);
position: relative;
.dropdown-toggle::after {
top: 0.5em;
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;
background: var(--card-background-color);
border-bottom: 1px solid var(--menu-item-delimiter-color) !important;
border-radius: 0;
border-radius: 0;
}
.dropdown-item:first-of-type,
@ -367,9 +388,9 @@ body.mobile .dropdown-menu {
border-top-right-radius: 6px;
}
.dropdown-item:last-of-type,
.dropdown-item:last-of-type,
.dropdown-item:has(+ .dropdown-divider),
.dropdown-custom-item:last-of-type,
.dropdown-custom-item:last-of-type,
.dropdown-custom-item:has(+ .dropdown-divider) {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
@ -392,10 +413,10 @@ body.mobile .dropdown-menu {
--menu-background-color: --menu-submenu-mobile-background-color;
--bs-dropdown-divider-margin-y: 0.25rem;
border-radius: 0;
max-height: 0;
max-height: 0;
transition: max-height 100ms ease-in;
display: block !important;
display: block !important;
&.show {
max-height: 1000px;
padding: 0.5rem 0.75rem !important;
@ -405,7 +426,7 @@ body.mobile .dropdown-menu {
&.submenu-open {
.dropdown-toggle {
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 {
background: var(--hover-item-background-color);
color: var(--hover-item-text-color);
}
}

View File

@ -689,6 +689,7 @@
"export_note": "Export note",
"delete_note": "Delete note",
"print_note": "Print note",
"view_revisions": "Note revisions...",
"save_revision": "Save revision",
"convert_into_attachment_failed": "Converting note '{{title}}' failed.",
"convert_into_attachment_successful": "Note '{{title}}' has been converted to attachment.",
@ -1750,8 +1751,8 @@
},
"note_title": {
"placeholder": "type note's title here...",
"created_on": "Created on {{date}}",
"last_modified": "Last modified on {{date}}"
"created_on": "Created on <Value />",
"last_modified": "Last modified on <Value />"
},
"search_result": {
"no_notes_found": "No notes have been found for given search parameters.",
@ -2132,8 +2133,18 @@
},
"breadcrumb_badges": {
"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_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_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;
align-items: center;
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 {
@ -53,11 +96,23 @@ body.experimental-feature-new-layout .breadcrumb-row {
}
.dropdown-item span,
.dropdown-item strong {
.dropdown-item strong,
.breadcrumb-last-item {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
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 { useMemo } from "preact/hooks";
import { useMemo, useState } from "preact/hooks";
import { Fragment } from "preact/jsx-runtime";
import NoteContext from "../components/note_context";
@ -12,6 +12,8 @@ import { useChildNotes, useNoteContext, useNoteLabel, useNoteProperty } from "./
import Icon from "./react/Icon";
import NoteLink from "./react/NoteLink";
import link_context_menu from "../menus/link_context_menu";
import { TitleEditor } from "./collections/board";
import server from "../services/server";
const COLLAPSE_THRESHOLD = 5;
const INITIAL_ITEMS = 2;
@ -27,10 +29,7 @@ export default function Breadcrumb() {
<>
{notePath.slice(0, INITIAL_ITEMS).map((item, index) => (
<Fragment key={item}>
{index === 0
? <BreadcrumbRoot noteContext={noteContext} />
: <BreadcrumbItem notePath={item} />
}
<BreadcrumbItem index={index} notePath={item} notePathLength={notePath.length} noteContext={noteContext} />
<BreadcrumbSeparator notePath={item} activeNotePath={notePath[index + 1]} noteContext={noteContext} />
</Fragment>
))}
@ -38,7 +37,7 @@ export default function Breadcrumb() {
{notePath.slice(-FINAL_ITEMS).map((item, index) => (
<Fragment key={item}>
<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>
))}
</>
@ -47,7 +46,7 @@ export default function Breadcrumb() {
<Fragment key={item}>
{index === 0
? <BreadcrumbRoot noteContext={noteContext} />
: <BreadcrumbItem notePath={item} />
: <BreadcrumbItem index={index} notePath={item} notePathLength={notePath.length} noteContext={noteContext} />
}
{(index < notePath.length - 1 || note?.hasChildren()) &&
<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 (
<NoteLink
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 }) {
return (
<Dropdown

View File

@ -1,23 +1,71 @@
.component.breadcrumb-badges {
contain: none;
}
.breadcrumb-badges {
display: flex;
gap: 5px;
contain: none;
min-width: 0;
flex-shrink: 1;
overflow: hidden;
--badge-radius: 12px;
.breadcrumb-badge {
display: flex;
align-items: center;
padding: 2px 6px;
border-radius: 12px;
border-radius: var(--badge-radius);
font-size: 0.75em;
background-color: var(--badge-background-color);
color: var(--badge-text-color);
background-color: var(--color, transparent);
color: white;
min-width: 0;
flex-shrink: 1;
&.clickable {
cursor: pointer;
&: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 { ComponentChildren } from "preact";
import { useIsNoteReadOnly, useNoteContext } from "./react/hooks";
import clsx from "clsx";
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 { useShareInfo } from "./shared_info";
import clsx from "clsx";
import { t } from "../services/i18n";
export default function BreadcrumbBadges() {
return (
<div className="breadcrumb-badges">
<ReadOnlyBadge />
<ShareBadge />
<BacklinksBadge />
</div>
);
}
@ -20,37 +25,113 @@ function ReadOnlyBadge() {
const { note, noteContext } = useNoteContext();
const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext);
const isExplicitReadOnly = note?.isLabelTruthy("readOnly");
const isTemporarilyEditable = noteContext?.viewScope?.readOnlyTemporarilyDisabled;
return (isReadOnly &&
<Badge
icon="bx bx-lock"
onClick={() => enableEditing()}>
{isExplicitReadOnly ? t("breadcrumb_badges.read_only_explicit") : t("breadcrumb_badges.read_only_auto")}
</Badge>
);
if (isTemporarilyEditable) {
return <Badge
icon="bx bx-lock-open-alt"
text={t("breadcrumb_badges.read_only_temporarily_disabled")}
tooltip={t("breadcrumb_badges.read_only_temporarily_disabled_description")}
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() {
const { note } = useNoteContext();
const { isSharedExternally, link } = useShareInfo(note);
const { isSharedExternally, link, linkHref } = useShareInfo(note);
return (link &&
<Badge
icon={isSharedExternally ? "bx bx-world" : "bx bx-link"}
>
{isSharedExternally ? t("breadcrumb_badges.shared_publicly") : t("breadcrumb_badges.shared_locally")}
</Badge>
text={isSharedExternally ? t("breadcrumb_badges.shared_publicly") : t("breadcrumb_badges.shared_locally")}
tooltip={isSharedExternally ?
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 (
<div
className={clsx("breadcrumb-badge", { "clickable": !!onClick })}
ref={containerRef}
className={clsx("breadcrumb-badge", className, { "clickable": !!onClick })}
onClick={onClick}
>
<Icon icon={icon} />&nbsp;
{children}
{href ? <a href={href}>{content}</a> : <span>{content}</span>}
</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 ActionButton, { ActionButtonProps } from "./react/ActionButton";
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 server from "../services/server";
import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
@ -20,6 +20,7 @@ import RawHtml from "./react/RawHtml";
import { ViewTypeOptions } from "./collections/interface";
import attributes from "../services/attributes";
import LoadResults from "../services/load_results";
import { isExperimentalFeatureEnabled } from "../services/experimental_features";
export interface FloatingButtonContext {
parentComponent: Component;
@ -76,6 +77,8 @@ export const POPUP_HIDDEN_FLOATING_BUTTONS: FloatingButtonsList = [
ToggleReadOnlyButton
];
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode;
return isEnabled && <FloatingButton
@ -308,22 +311,9 @@ function InAppHelpButton({ note }: FloatingButtonContext) {
}
function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
let [ backlinkCount, setBacklinkCount ] = useState(0);
let [ popupOpen, setPopupOpen ] = useState(false);
const [ popupOpen, setPopupOpen ] = useState(false);
const backlinksContainerRef = useRef<HTMLDivElement>(null);
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();
});
const backlinkCount = useBacklinkCount(note, isDefaultViewMode);
// Determine the max height of the container.
const { windowHeight } = useWindowSize();
@ -336,7 +326,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
}
}, [ popupOpen, windowHeight ]);
const isEnabled = isDefaultViewMode && backlinkCount > 0;
const isEnabled = !isNewLayout && isDefaultViewMode && backlinkCount > 0;
return (isEnabled &&
<div className="backlinks-widget has-overflow">
<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>([]);
function refresh() {
server.get<BacklinksResponse>(`note-map/${note.noteId}/backlinks`).then(async (backlinks) => {
// prefetch all
const noteIds = backlinks
.filter(bl => "noteId" in bl)
.map((bl) => bl.noteId);
.filter(bl => "noteId" in bl)
.map((bl) => bl.noteId);
await froca.getNotes(noteIds);
setBacklinks(backlinks);
});

View File

@ -1,23 +1,60 @@
import { t } from "../services/i18n";
import { type ComponentChild } from "preact";
import { formatDateTime } from "../utils/formatters";
import { useNoteContext } from "./react/hooks";
import { useNoteContext, useStaticTooltip } from "./react/hooks";
import { joinElements } from "./react/react_utils";
import { useNoteMetadata } from "./ribbon/NoteInfoTab";
import { Trans } from "react-i18next";
import { useRef } from "preact/hooks";
export default function NoteTitleDetails() {
const { note } = useNoteContext();
const { note, noteContext } = useNoteContext();
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 (
<div className="title-details">
{joinElements([
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>
], " • ")}
{joinElements(items, " • ")}
</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 type { Attribute } from "../../services/attribute_parser.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
const TPL = /*html*/`
<div class="attr-detail tn-tool-dialog">
@ -309,6 +310,8 @@ interface SearchRelatedResponse {
count: number;
}
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export default class AttributeDetailWidget extends NoteContextAwareWidget {
private $title!: JQuery<HTMLElement>;
private $inputName!: JQuery<HTMLElement>;
@ -579,6 +582,13 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
.css("top", y - offset.top + 70)
.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") {
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 }: {
currentValue?: string;
placeholder?: string;
save: (newValue: string) => void;
save: (newValue: string) => void | Promise<void>;
dismiss: () => void;
isNewItem?: boolean;
mode?: "normal" | "multiline" | "relation";

View File

@ -31,6 +31,7 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
flex-grow: 1;
display: flex;
align-items: center;
margin-block: 0;
}
.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 { 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 { 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 StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter";
import FormattingToolbar from "../ribbon/FormattingToolbar";
import PromotedAttributes from "../PromotedAttributes";
import FloatingButtons from "../FloatingButtons";
import { DESKTOP_FLOATING_BUTTONS, MOBILE_FLOATING_BUTTONS, POPUP_HIDDEN_FLOATING_BUTTONS } from "../FloatingButtonsDefinitions";
import utils from "../../services/utils";
import tree from "../../services/tree";
import froca from "../../services/froca";
import NoteIcon from "../note_icon";
import NoteTitleWidget from "../note_title";
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 StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter";
import FormattingToolbar from "../ribbon/FormattingToolbar";
import MobileEditorToolbar from "../type_widgets/text/mobile_editor_toolbar";
import { t } from "../../services/i18n";
import appContext from "../../components/app_context";
import BreadcrumbBadges from "../BreadcrumbBadges";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export default function PopupEditor() {
const [ shown, setShown ] = useState(false);
@ -61,7 +67,10 @@ export default function PopupEditor() {
<NoteContextContext.Provider value={noteContext}>
<DialogWrapper>
<Modal
title={<TitleRow />}
title={<>
<TitleRow />
{isNewLayout && <BreadcrumbBadges />}
</>}
customTitleBarButtons={[{
iconClassName: "bx-expand-alt",
title: t("popup-editor.maximize"),
@ -75,19 +84,17 @@ export default function PopupEditor() {
className="popup-editor-dialog"
size="lg"
show={shown}
onShown={() => {
parentComponent?.handleEvent("focusOnDetail", { ntxId: noteContext.ntxId });
}}
onShown={() => parentComponent?.handleEvent("focusOnDetail", { ntxId: noteContext.ntxId })}
onHidden={() => setShown(false)}
keepInDom // needed for faster loading
noFocus // automatic focus breaks block popup
>
<ReadOnlyNoteInfoBar />
{!isNewLayout && <ReadOnlyNoteInfoBar />}
<PromotedAttributes />
{isMobile
? <MobileEditorToolbar inPopupEditor />
: <StandaloneRibbonAdapter component={FormattingToolbar} />}
? <MobileEditorToolbar inPopupEditor />
: <StandaloneRibbonAdapter component={FormattingToolbar} />}
<FloatingButtons items={items} />
<NoteDetail />
@ -95,7 +102,7 @@ export default function PopupEditor() {
</Modal>
</DialogWrapper>
</NoteContextContext.Provider>
)
);
}
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() ?? ""}`}>
{children}
</div>
)
);
}
export function TitleRow() {
@ -116,5 +123,5 @@ export function TitleRow() {
<NoteIcon />
<NoteTitleWidget />
</div>
)
);
}

View File

@ -29,30 +29,73 @@ body.desktop .note-title-widget input.note-title {
font-size: 180%;
}
body.experimental-feature-new-layout .title-row,
body.experimental-feature-new-layout .title-details {
max-width: var(--max-content-width);
}
body.experimental-feature-new-layout {
.title-row,
.title-details {
max-width: var(--max-content-width);
padding: 0;
padding-inline-start: 24px;
}
body.experimental-feature-new-layout .title-row {
margin-top: 2em;
margin-left: 12px;
}
.title-row {
margin-left: 12px;
body.experimental-feature-new-layout .title-details {
margin-top: 0;
contain: none;
padding: 0;
padding-inline-start: 24px;
opacity: 0.85;
display: flex;
gap: 0.25em;
margin: 0;
list-style-type: none;
margin-bottom: 2em;
}
.note-icon-widget {
padding: 0;
width: 41px;
}
}
body.experimental-feature-new-layout.prefers-centered-content .title-row,
body.experimental-feature-new-layout.prefers-centered-content .title-details {
margin-inline: auto;
.note-split.type-code:not(.mime-text-x-sqlite) .title-row,
.note-split.type-code:not(.mime-text-x-sqlite) .title-details {
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.getMimeTypeClass(note.mime));
this.$widget.addClass(`view-mode-${this.noteContext?.viewScope?.viewMode ?? "default"}`);
this.$widget.toggleClass(["bgfx", "options"], note.isOptions());
this.$widget.toggleClass("protected", note.isProtected);

View File

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

View File

@ -161,11 +161,16 @@ export function FormDropdownDivider() {
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);
return (
<li className={`dropdown-item dropdown-submenu ${openOnMobile ? "submenu-open" : ""}`}>
<li className={clsx("dropdown-item dropdown-submenu", { "submenu-open": openOnMobile, "dropstart": dropStart })}>
<span
className="dropdown-toggle"
onClick={(e) => {
@ -184,5 +189,5 @@ export function FormDropdownSubmenu({ icon, title, children }: { icon: string, t
{children}
</ul>
</li>
)
);
}

View File

@ -201,7 +201,7 @@ export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean):
return [
(value === "true"),
(newValue) => setValue(newValue ? "true" : "false")
]
];
}
/**
@ -217,17 +217,18 @@ export function useTriliumOptionInt(name: OptionNames): [number, (newValue: numb
return [
(parseInt(value, 10)),
(newValue) => setValue(newValue)
]
];
}
/**
* 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 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.
*/
export function useTriliumOptionJson<T>(name: OptionNames): [ T, (newValue: T) => Promise<void> ] {
const [ value, setValue ] = useTriliumOption(name);
export function useTriliumOptionJson<T>(name: OptionNames, needsRefresh?: boolean): [ T, (newValue: T) => Promise<void> ] {
const [ value, setValue ] = useTriliumOption(name, needsRefresh);
useDebugValue(name);
return [
(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) {
const [ isReadOnly, setIsReadOnly ] = useState<boolean | undefined>(undefined);
const enableEditing = useCallback(() => {
const enableEditing = useCallback((enabled = true) => {
if (noteContext?.viewScope) {
noteContext.viewScope.readOnlyTemporarilyDisabled = true;
noteContext.viewScope.readOnlyTemporarilyDisabled = enabled;
appContext.triggerEvent("readOnlyTemporarilyDisabled", {noteContext});
}
}, [noteContext]);
@ -862,7 +863,7 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
useTriliumEvent("readOnlyTemporarilyDisabled", ({noteContext: eventNoteContext}) => {
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 ActionButton from "../react/ActionButton";
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 { ParentComponent } from "../react/react_utils";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
@ -98,7 +98,7 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
}
<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 icon="bx bx-trash destructive-action-icon" text={t("note_actions.delete_note")} destructive
disabled={isInOptionsOrHelp}
@ -114,23 +114,22 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
function DevelopmentActions({ note, noteContext }: { note: FNote, noteContext?: NoteContext }) {
return (
<>
<FormListHeader text="Development-only Actions" />
<FormDropdownSubmenu title="Development Actions" icon="bx bx-wrench" dropStart>
<FormListItem
icon="bx bx-printer"
onClick={() => window.open(`/?print=#root/${note.noteId}`, "_blank")}
>Open print page</FormListItem>
{note.type === "text" && (
<FormListItem
icon="bx bx-error"
onClick={() => {
noteContext?.getTextEditor(editor => {
editor.editing.view.change(() => {
throw new Error("Editor crashed.");
});
<FormListItem
icon="bx bx-error"
disabled={note.type !== "text"}
onClick={() => {
noteContext?.getTextEditor(editor => {
editor.editing.view.change(() => {
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"),
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"
&& !(await noteContext?.isReadOnly()),
toggleCommand: "toggleRibbonTabClassicEditor",

View File

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

View File

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