mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
Own browser UI sessions and shared navigation
This commit is contained in:
1 parent
cfdc698775
commit
273079e827
28 files changed
+1948
-121
No files matched your search
@@ -1,11 +1,10 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
|
||||
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
|
||||
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
/**
|
||||
* Compatibility facade consumed by Commonlib while browser presentation is
|
||||
@@ -14,26 +13,26 @@ import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
|
||||
readonly context: T;
|
||||
|
||||
constructor(context: T) {
|
||||
const dialogueController = new AbortController();
|
||||
constructor(context: T, ui: BrowserUiScope = createBrowserUiScope(context)) {
|
||||
const notifications = new BrowserUiNotifications({
|
||||
document: _activeDocument,
|
||||
document: ui.document,
|
||||
});
|
||||
super({
|
||||
ui: createBrowserUi({
|
||||
document: _activeDocument,
|
||||
signal: dialogueController.signal,
|
||||
document: ui.document,
|
||||
signal: ui.signal,
|
||||
renderMarkdown: ({ container, markdown }) => {
|
||||
renderMessageMarkdownInto(container, markdown);
|
||||
},
|
||||
}),
|
||||
notifications,
|
||||
createActionAnchor: () => createNativeElement(_activeDocument, "a"),
|
||||
createActionAnchor: () => createNativeElement(ui.document, "a"),
|
||||
});
|
||||
this.context = context;
|
||||
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
||||
dialogueController.abort();
|
||||
if (ui.signal.aborted) {
|
||||
notifications.dispose();
|
||||
});
|
||||
} else {
|
||||
ui.signal.addEventListener("abort", () => notifications.dispose(), { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { mount } from "svelte";
|
||||
import type { BrowserUiOptions } from "./BrowserUiScope";
|
||||
import { mount, unmount } from "svelte";
|
||||
import MenuView from "./ui/MenuView.svelte";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type MountedMenu = ReturnType<typeof mount>;
|
||||
type MenuSession = {
|
||||
waitingForClose: PromiseWithResolvers<void>;
|
||||
element: HTMLElement;
|
||||
component?: MountedMenu;
|
||||
abortListener?: () => void;
|
||||
closed: boolean;
|
||||
};
|
||||
|
||||
export class MenuItem {
|
||||
type = "item";
|
||||
title = "";
|
||||
@@ -29,7 +39,15 @@ export class Menu {
|
||||
type = "menu";
|
||||
items: (MenuItem | MenuSeparator)[] = [];
|
||||
|
||||
constructor() {}
|
||||
private readonly document: Document;
|
||||
private readonly signal?: AbortSignal;
|
||||
private activeSession?: MenuSession;
|
||||
|
||||
constructor(options: BrowserUiOptions = {}) {
|
||||
this.document = options.document ?? _activeDocument;
|
||||
this.signal = options.signal;
|
||||
}
|
||||
|
||||
addItem(callback: (item: MenuItem) => void) {
|
||||
const item = new MenuItem();
|
||||
callback(item);
|
||||
@@ -41,32 +59,75 @@ export class Menu {
|
||||
return this;
|
||||
}
|
||||
waitingForClose?: PromiseWithResolvers<void>;
|
||||
showAtPosition(pos: { x: number; y: number }) {
|
||||
const el = createNativeElement(_activeDocument, "div");
|
||||
if (this.waitingForClose) {
|
||||
this.waitingForClose.resolve();
|
||||
|
||||
showAtPosition(pos: { x: number; y: number }): Promise<void> {
|
||||
if (this.activeSession) {
|
||||
this.closeSession(this.activeSession);
|
||||
}
|
||||
this.waitingForClose = promiseWithResolvers<void>();
|
||||
mount(MenuView, {
|
||||
target: el,
|
||||
props: {
|
||||
items: this.items,
|
||||
closeMenu: () => {
|
||||
this.waitingForClose?.resolve();
|
||||
this.waitingForClose = undefined;
|
||||
if (this.signal?.aborted) {
|
||||
this.waitingForClose = undefined;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const waitingForClose = promiseWithResolvers<void>();
|
||||
const element = createNativeElement(this.document, "div");
|
||||
const session: MenuSession = {
|
||||
waitingForClose,
|
||||
element,
|
||||
closed: false,
|
||||
};
|
||||
this.activeSession = session;
|
||||
this.waitingForClose = waitingForClose;
|
||||
|
||||
const closeMenu = () => this.closeSession(session);
|
||||
session.abortListener = closeMenu;
|
||||
this.signal?.addEventListener("abort", closeMenu, { once: true });
|
||||
|
||||
try {
|
||||
session.component = mount(MenuView, {
|
||||
target: element,
|
||||
props: {
|
||||
items: this.items,
|
||||
closeMenu,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
},
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
},
|
||||
});
|
||||
_activeDocument.body.appendChild(el);
|
||||
void this.waitingForClose.promise.finally(() => {
|
||||
el.remove();
|
||||
});
|
||||
return this.waitingForClose.promise;
|
||||
});
|
||||
if (!session.closed) {
|
||||
this.document.body.appendChild(element);
|
||||
} else if (session.component) {
|
||||
fireAndForget(() => unmount(session.component!));
|
||||
}
|
||||
} catch (error) {
|
||||
this.closeSession(session);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return waitingForClose.promise;
|
||||
}
|
||||
|
||||
private closeSession(session: MenuSession): void {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
if (session.abortListener) {
|
||||
this.signal?.removeEventListener("abort", session.abortListener);
|
||||
session.abortListener = undefined;
|
||||
}
|
||||
if (this.activeSession === session) {
|
||||
this.activeSession = undefined;
|
||||
this.waitingForClose = undefined;
|
||||
}
|
||||
|
||||
session.element.remove();
|
||||
if (session.component) {
|
||||
fireAndForget(() => unmount(session.component!));
|
||||
}
|
||||
session.waitingForClose.resolve();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.waitingForClose?.resolve();
|
||||
this.waitingForClose = undefined;
|
||||
if (this.activeSession) {
|
||||
this.closeSession(this.activeSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
|
||||
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
|
||||
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
export class BrowserModal {
|
||||
contentEl: HTMLElement;
|
||||
@@ -15,14 +16,14 @@ export class BrowserModal {
|
||||
modalEl: HTMLElement;
|
||||
isOpen: boolean = false;
|
||||
baseEl: HTMLElement;
|
||||
constructor() {
|
||||
const baseEl = createNativeElement(_activeDocument, "popup");
|
||||
constructor(document: Document = _activeDocument) {
|
||||
const baseEl = createNativeElement(document, "popup");
|
||||
this.baseEl = baseEl;
|
||||
this.contentEl = createNativeElement(_activeDocument, "div");
|
||||
this.contentEl = createNativeElement(document, "div");
|
||||
this.contentEl.className = "modal-content";
|
||||
this.titleEl = createNativeElement(_activeDocument, "div");
|
||||
this.titleEl = createNativeElement(document, "div");
|
||||
this.titleEl.className = "modal-title";
|
||||
this.modalEl = createNativeElement(_activeDocument, "div");
|
||||
this.modalEl = createNativeElement(document, "div");
|
||||
this.modalEl.className = "modal";
|
||||
this.modalEl.hidden = true;
|
||||
this.modalEl.appendChild(this.titleEl);
|
||||
@@ -33,7 +34,7 @@ export class BrowserModal {
|
||||
this.isOpen = true;
|
||||
this.modalEl.hidden = false;
|
||||
if (!this.baseEl.parentElement) {
|
||||
_activeDocument.body.appendChild(this.baseEl);
|
||||
this.baseEl.ownerDocument.body.appendChild(this.baseEl);
|
||||
}
|
||||
this.onOpen();
|
||||
}
|
||||
@@ -57,9 +58,10 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
context: C,
|
||||
dependents: SvelteDialogManagerDependencies<C>,
|
||||
component: ComponentHasResult<T, U>,
|
||||
initialData?: U
|
||||
initialData?: U,
|
||||
private readonly ui?: BrowserUiScope
|
||||
) {
|
||||
super();
|
||||
super(ui?.document ?? _activeDocument);
|
||||
this.session = new SvelteDialogSession({
|
||||
surface: this,
|
||||
context,
|
||||
@@ -67,9 +69,15 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
dialogHost: DialogHost,
|
||||
component,
|
||||
initialData,
|
||||
signal: ui?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
override open(): void {
|
||||
if (this.ui?.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||
super.open();
|
||||
}
|
||||
|
||||
override onOpen(): void {
|
||||
this.session.onOpen();
|
||||
}
|
||||
@@ -83,11 +91,26 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
}
|
||||
}
|
||||
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
|
||||
constructor(
|
||||
context: T,
|
||||
dependencies: SvelteDialogManagerDependencies<T>,
|
||||
private readonly ui: BrowserUiScope = createBrowserUiScope(context)
|
||||
) {
|
||||
super(context, dependencies);
|
||||
}
|
||||
|
||||
override async openSvelteDialog<TT, TU>(
|
||||
component: ComponentHasResult<TT, TU>,
|
||||
initialData?: TU
|
||||
): Promise<TT | undefined> {
|
||||
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(this.context, this.dependents, component, initialData);
|
||||
if (this.ui.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(
|
||||
this.context,
|
||||
this.dependents,
|
||||
component,
|
||||
initialData,
|
||||
this.ui
|
||||
);
|
||||
dialog.open();
|
||||
return await dialog.waitForClose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
export interface BrowserUiOptions {
|
||||
readonly document?: Document;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Browser presentation target and lifetime owned by one application context. */
|
||||
export interface BrowserUiScope {
|
||||
readonly document: Document;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export function createBrowserUiScope(
|
||||
context: Pick<ServiceContext, "events">,
|
||||
options: BrowserUiOptions = {}
|
||||
): BrowserUiScope {
|
||||
const controller = new AbortController();
|
||||
const parent = options.signal;
|
||||
let unregisterUnload: (() => void) | undefined;
|
||||
const end = (reason: unknown): void => {
|
||||
if (controller.signal.aborted) return;
|
||||
unregisterUnload?.();
|
||||
unregisterUnload = undefined;
|
||||
parent?.removeEventListener("abort", onParentAbort);
|
||||
controller.abort(reason);
|
||||
};
|
||||
const onParentAbort = () => end(parent?.reason);
|
||||
if (parent?.aborted) {
|
||||
controller.abort(parent.reason);
|
||||
} else {
|
||||
unregisterUnload = context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => end(new Error("Plug-in unloaded")));
|
||||
parent?.addEventListener("abort", onParentAbort, { once: true });
|
||||
}
|
||||
return { document: options.document ?? _activeDocument, signal: controller.signal };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/ser
|
||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
|
||||
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
||||
import type { BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||
API: InjectableAPIService<T>;
|
||||
@@ -20,15 +21,19 @@ export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIServic
|
||||
override get dialogToCopy() {
|
||||
return DialogToCopy;
|
||||
}
|
||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>) {
|
||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>, ui?: BrowserUiScope) {
|
||||
const browserConfirm = dependents.API.confirm;
|
||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(context, {
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
config: dependents.config,
|
||||
replicator: dependents.replicator,
|
||||
confirm: browserConfirm,
|
||||
control: dependents.control,
|
||||
});
|
||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(
|
||||
context,
|
||||
{
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
config: dependents.config,
|
||||
replicator: dependents.replicator,
|
||||
confirm: browserConfirm,
|
||||
control: dependents.control,
|
||||
},
|
||||
ui
|
||||
);
|
||||
super(context, {
|
||||
dialogManager: obsidianSvelteDialogManager,
|
||||
APIService: dependents.API,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
|
||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||
import { BrowserConfirm } from "./BrowserConfirm";
|
||||
import { createBrowserUiScope, type BrowserUiOptions, type BrowserUiScope } from "./BrowserUiScope";
|
||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||
@@ -41,6 +42,7 @@ export interface LiveSyncBrowserRestartPolicy {
|
||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
deviceValues: ValueStore & ValueDeleter;
|
||||
context?: T;
|
||||
ui?: BrowserUiOptions;
|
||||
getSystemVaultName?: () => string;
|
||||
settings?: LiveSyncBrowserSettingsPersistence;
|
||||
restart?: LiveSyncBrowserRestartPolicy;
|
||||
@@ -56,15 +58,18 @@ class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValu
|
||||
|
||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||
readonly browserUi: BrowserUiScope;
|
||||
|
||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||
const context =
|
||||
options.context ??
|
||||
(new ServiceContext({
|
||||
translate: translateLiveSyncMessage,
|
||||
}) as T);
|
||||
const browserUi = createBrowserUiScope(context, options.ui);
|
||||
const API = new LiveSyncBrowserAPIService(context, {
|
||||
...options.API,
|
||||
confirm: new BrowserConfirm(context),
|
||||
confirm: new BrowserConfirm(context, browserUi),
|
||||
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
|
||||
});
|
||||
const conflict = new InjectableConflictService(context);
|
||||
@@ -147,13 +152,17 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
APIService: API,
|
||||
replicatorService: replicator,
|
||||
});
|
||||
const ui = new LiveSyncBrowserUIService(context, {
|
||||
API,
|
||||
appLifecycle,
|
||||
config,
|
||||
control,
|
||||
replicator,
|
||||
});
|
||||
const ui = new LiveSyncBrowserUIService(
|
||||
context,
|
||||
{
|
||||
API,
|
||||
appLifecycle,
|
||||
config,
|
||||
control,
|
||||
replicator,
|
||||
},
|
||||
browserUi
|
||||
);
|
||||
|
||||
super(context, {
|
||||
API,
|
||||
@@ -175,6 +184,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
ui,
|
||||
vault,
|
||||
});
|
||||
this.browserUi = browserUi;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user