feat(share): open a link's note in a browser tab or window from its menu

A link's context menu on a shared page offered the app's ways of opening
a note: a new tab, split or window, and the quick edit popup. The page
has no tab bar, no splits and no popup, so the tab opened in the host's
`TabManager` where nothing shows it, and the others did nothing.

On a shared page, `getItems()` now offers only a new tab and a new
window, and `handleLinkContextMenuItem()` opens the note's shared page
in a browser tab, or a browser window (`popup`), instead of running the
app's commands; a note with no shared page opens nowhere. The menu
learns the shared page of a note from `setShareLinkResolver()`, which
the share host sets to the links froca's share source collects, so every
link menu a hosted view shows follows it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Elian Doran
2026-10-10 11:49:49 +03:00
co-authored by Claude Opus 5.5
parent d7cdee8b4b
commit 10f49d0726
3 changed files with 74 additions and 3 deletions
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
triggerCommand: vi.fn(),
openContextWithNote: vi.fn(),
isMobile: vi.fn(() => false),
isShare: false,
isDesktop: vi.fn(() => true),
getClosestNtxId: vi.fn((): string | null => null),
/** The splits of the active tab, which decide the "new split" vs "other split" wording. */
@@ -47,7 +48,10 @@ vi.mock("../services/i18n", () => ({ t: (key: string) => key }));
vi.mock("../services/utils", () => ({
default: { isDesktop: mocks.isDesktop },
escapeHtml: (text: string) => text,
isMobile: mocks.isMobile
isMobile: mocks.isMobile,
get isShare() {
return mocks.isShare;
}
}));
vi.mock("../widgets/widget_utils", () => ({ getClosestNtxId: mocks.getClosestNtxId }));
@@ -116,6 +120,16 @@ describe("getItems", () => {
.toMatchObject({ title: "link_context_menu.open_note_in_new_split" });
});
it("offers only a new tab and a new window on a shared page, which has no splits or popup", () => {
mocks.isShare = true;
try {
expect(linkContextMenu.getItems(contextMenuEvent()).map((item) => "command" in item && item.command))
.toEqual([ "openNoteInNewTab", "openNoteInNewWindow" ]);
} finally {
mocks.isShare = false;
}
});
/** For a menu with entries of its own, which lists quick edit and folds the rest away. */
it("folds the three places into one submenu, quick edit standing on its own", () => {
const open = linkContextMenu.getOpenNoteItem(contextMenuEvent());
@@ -135,6 +149,29 @@ describe("getItems", () => {
});
describe("handleLinkContextMenuItem", () => {
it("opens the note's shared page in a browser tab or window on a shared page", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
mocks.isShare = true;
linkContextMenu.setShareLinkResolver((noteId) => (noteId === "n1" ? "./first-note" : null));
try {
expect(handle("openNoteInNewTab")).toBe(true);
expect(open).toHaveBeenLastCalledWith("./first-note", "_blank", "noopener");
expect(handle("openNoteInNewWindow")).toBe(true);
expect(open).toHaveBeenLastCalledWith("./first-note", "_blank", "noopener,popup");
expect(mocks.openContextWithNote).not.toHaveBeenCalled();
expect(mocks.triggerCommand).not.toHaveBeenCalled();
// A note without a shared page has nowhere to be opened.
open.mockClear();
expect(linkContextMenu.handleLinkContextMenuItem("openNoteInNewTab", contextMenuEvent(), "root/n2")).toBe(false);
expect(open).not.toHaveBeenCalled();
} finally {
mocks.isShare = false;
linkContextMenu.setShareLinkResolver(undefined);
open.mockRestore();
}
});
it("opens a new tab under the given hoisting, falling back to the active context's", () => {
expect(handle("openNoteInNewTab", VIEW_SCOPE, "explicitHoist")).toBe(true);
expect(mocks.openContextWithNote).toHaveBeenCalledWith("root/n1", {
+34 -2
View File
@@ -8,7 +8,7 @@ import type FNote from "../entities/fnote.js";
import froca from "../services/froca.js";
import { t } from "../services/i18n.js";
import type { ViewScope } from "../services/link.js";
import utils, { escapeHtml, isMobile } from "../services/utils.js";
import utils, { escapeHtml, isMobile, isShare } from "../services/utils.js";
import { getClosestNtxId } from "../widgets/widget_utils.js";
import contextMenu, { type ContextMenuOptions, type MenuItem } from "./context_menu.js";
import { submenuItem } from "./context_menu_utils.js";
@@ -87,10 +87,26 @@ function getOriginBelow(anchor: Element, target: Element = anchor): LinkMenuOrig
return { pageX: left + window.scrollX, pageY: bottom + window.scrollY, target };
}
/**
* The ways of opening a link. A shared page has no splits and no popup, so it offers only a new tab
* and a new window, which open the note's shared page in the browser.
*/
function getItems(e: LinkMenuOrigin | GeoMouseEvent): MenuItem<CommandNames>[] {
if (isShare) {
return getOpenItems(e).filter((item) => "command" in item && item.command !== "openNoteInNewSplit");
}
return [ ...getOpenItems(e), getQuickEditItem() ];
}
/** On a shared page, the URL of a note's shared page, or `null` for a note that has none. */
let resolveShareLink: ((noteId: string) => string | null) | undefined;
/** Sets how a shared page finds the shared page of a note it opens, which only its host knows. */
function setShareLinkResolver(resolver: ((noteId: string) => string | null) | undefined) {
resolveShareLink = resolver;
}
/** The places the note can be opened in, without the quick edit popup. */
function getOpenItems(e: LinkMenuOrigin | GeoMouseEvent): MenuItem<CommandNames>[] {
const ntxId = getNtxId(e);
@@ -125,6 +141,10 @@ function getOpenNoteItem(e: LinkMenuOrigin | GeoMouseEvent): MenuItem<CommandNam
}
function handleLinkContextMenuItem(command: string | undefined, e: LinkMenuOrigin | GeoMouseEvent, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) {
if (isShare) {
return openSharedPage(command, notePath);
}
if (!hoistedNoteId) {
hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId ?? null;
}
@@ -150,6 +170,17 @@ function handleLinkContextMenuItem(command: string | undefined, e: LinkMenuOrigi
return false;
}
/** Opens the shared page of the note at `notePath` in a new browser tab or window. */
function openSharedPage(command: string | undefined, notePath: string) {
const link = resolveShareLink?.(notePath.split("/").at(-1) ?? notePath);
if (!link || (command !== "openNoteInNewTab" && command !== "openNoteInNewWindow")) {
return false;
}
window.open(link, "_blank", command === "openNoteInNewWindow" ? "noopener,popup" : "noopener");
return true;
}
/**
* The actions on the attachment a link points to, each group after a separator. A menu opened on
* an embed of it has the commands of the embed after the first group. A menu opened in a note shown
@@ -398,5 +429,6 @@ export default {
getQuickEditItem,
getOpenNoteItem,
handleLinkContextMenuItem,
openContextMenu
openContextMenu,
setShareLinkResolver
};
@@ -2,6 +2,7 @@ import appContext from "@triliumnext/client/src/components/app_context.js";
import Component from "@triliumnext/client/src/components/component.js";
import TabManager from "@triliumnext/client/src/components/tab_manager.js";
import type FNote from "@triliumnext/client/src/entities/fnote.js";
import linkContextMenu from "@triliumnext/client/src/menus/link_context_menu.js";
import froca from "@triliumnext/client/src/services/froca.js";
import options, { type OptionValue } from "@triliumnext/client/src/services/options.js";
import { ParentComponent } from "@triliumnext/client/src/widgets/react/react_utils.js";
@@ -63,6 +64,7 @@ function loadPayload(noteId: string, payload: AppPayload): HostedApp | null {
.flatMap(([ name, value ]) => (value === null ? [] : [ [ name, value ] ]))));
froca.setSource(createShareFrocaSource(links));
linkContextMenu.setShareLinkResolver((linkedNoteId) => links[linkedNoteId] ?? null);
froca.addResp({
...rows,
attributes: [ ...rows.attributes, {