refactor: consume Commonlib as a package

This commit is contained in:
vorotamoroz
2026-07-17 11:13:16 +00:00
parent 6475d32769
commit a721d3b602
665 changed files with 3438 additions and 31592 deletions
+107
View File
@@ -0,0 +1,107 @@
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import MessageBox from "./ui/MessageBox.svelte";
import TextInputBox from "./ui/TextInputBox.svelte";
import { mount } from "svelte";
import { promiseWithResolvers } from "octagonal-wheels/promises";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
function displayMessageBox<T, U extends string[]>(
message: string,
buttons: U,
title: string,
commit: (ret: U[number]) => T
): Promise<T> {
const el = _activeDocument.createElement("div");
const p = promiseWithResolvers<T>();
mount(MessageBox, {
target: el,
props: {
message,
buttons: buttons as string[],
title: title,
commit: (action: U[number]) => {
const ret = commit(action);
p.resolve(ret);
},
},
});
_activeDocument.body.appendChild(el);
void p.promise.finally(() => {
el.remove();
});
return p.promise;
}
function promptForInput(
title: string,
key: string,
placeholder: string,
isPassword?: boolean
): Promise<string | false> {
const el = _activeDocument.createElement("div");
const p = promiseWithResolvers<string | false>();
mount(TextInputBox, {
target: el,
props: {
title,
message: key,
placeholder,
isPassword,
commit: (text: string | false) => {
p.resolve(text);
},
},
});
_activeDocument.body.appendChild(el);
void p.promise.finally(() => {
el.remove();
});
return p.promise;
}
export class BrowserConfirm<T extends ServiceContext> implements Confirm {
_context: T;
constructor(context: T) {
this._context = context;
}
askYesNo(message: string): Promise<"yes" | "no"> {
return displayMessageBox(message, ["Yes", "No"] as const, "Confirm", (action) =>
action == "Yes" ? "yes" : "no"
);
}
askString(title: string, key: string, placeholder: string, isPassword?: boolean): Promise<string | false> {
return promptForInput(title, key, placeholder, isPassword);
}
askYesNoDialog(
message: string,
opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number }
): Promise<"yes" | "no"> {
return displayMessageBox(message, ["Yes", "No"] as const, opt.title ?? "Confirm", (action) =>
action == "Yes" ? "yes" : "no"
);
}
askSelectString(message: string, items: string[]): Promise<string> {
return displayMessageBox(message, [...items] as const, "Confirm", (action) => action);
}
askSelectStringDialogue<T extends readonly string[]>(
message: string,
buttons: T,
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
return displayMessageBox(message, [...buttons] as const, opt.title ?? "Confirm", (action) => action);
}
askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void): void {
throw new Error("Method not implemented.");
}
confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number
): Promise<(typeof buttons)[number] | false> {
return displayMessageBox(contentMd, [...buttons] as const, title ?? "Confirm", (action) => action);
}
}
+71
View File
@@ -0,0 +1,71 @@
import { promiseWithResolver, type PromiseWithResolvers } from "octagonal-wheels/promises";
import { mount } from "svelte";
import MenuView from "./ui/MenuView.svelte";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
export class MenuItem {
type = "item";
title = "";
handler?: () => void | Promise<void>;
icon: string = "";
setTitle(title: string) {
this.title = title;
return this;
}
onClick(callback: () => void | Promise<void>) {
this.handler = callback;
return this;
}
setIcon(icon: string | null) {
this.icon = icon || "";
return this;
}
}
export class MenuSeparator {
type = "separator";
}
export class Menu {
type = "menu";
items: (MenuItem | MenuSeparator)[] = [];
constructor() {}
addItem(callback: (item: MenuItem) => void) {
const item = new MenuItem();
callback(item);
this.items.push(item);
return this;
}
addSeparator() {
this.items.push(new MenuSeparator());
return this;
}
waitingForClose?: PromiseWithResolvers<void>;
showAtPosition(pos: { x: number; y: number }) {
const el = _activeDocument.createElement("div");
if (this.waitingForClose) {
this.waitingForClose.resolve();
}
this.waitingForClose = promiseWithResolver<void>();
mount(MenuView, {
target: el,
props: {
items: this.items,
closeMenu: () => {
this.waitingForClose?.resolve();
this.waitingForClose = undefined;
},
x: pos.x,
y: pos.y,
},
});
_activeDocument.body.appendChild(el);
void this.waitingForClose.promise.finally(() => {
el.remove();
});
return this.waitingForClose.promise;
}
hide() {
this.waitingForClose?.resolve();
this.waitingForClose = undefined;
}
}
@@ -0,0 +1,79 @@
import {
type ComponentHasResult,
SvelteDialogManagerBase,
SvelteDialogMixIn,
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
export class ShimModal {
contentEl: HTMLElement;
titleEl: HTMLElement;
modalEl: HTMLElement;
isOpen: boolean = false;
baseEl: HTMLElement;
constructor() {
const baseEl = _activeDocument.createElement("popup");
this.baseEl = baseEl;
this.contentEl = _activeDocument.createElement("div");
this.contentEl.className = "modal-content";
this.titleEl = _activeDocument.createElement("div");
this.titleEl.className = "modal-title";
this.modalEl = _activeDocument.createElement("div");
this.modalEl.className = "modal";
this.modalEl.style.display = "none";
this.modalEl.appendChild(this.titleEl);
this.modalEl.appendChild(this.contentEl);
this.baseEl.appendChild(this.modalEl);
}
open() {
this.isOpen = true;
this.modalEl.style.display = "block";
if (!this.baseEl.parentElement) {
_activeDocument.body.appendChild(this.baseEl);
}
this.onOpen();
}
close() {
this.isOpen = false;
this.baseEl.style.display = "none";
this.baseEl.remove();
this.onClose();
}
onOpen() {}
onClose() {}
setPlaceholder(p: string) {}
setTitle(t: string) {
this.titleEl.textContent = t;
}
}
const BrowserSvelteDialogBase = SvelteDialogMixIn(ShimModal, DialogHost);
export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceContext> extends BrowserSvelteDialogBase<
T,
U,
C
> {
constructor(
context: C,
dependents: SvelteDialogManagerDependencies<C>,
component: ComponentHasResult<T, U>,
initialData?: U
) {
super();
this.initDialog(context, dependents, component, initialData);
}
}
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
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);
dialog.open();
return await dialog.waitForClose();
}
}
@@ -0,0 +1,25 @@
import type { BrowserServiceHostDependencies } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIService<T> {
override get dialogToCopy() {
return DialogToCopy;
}
constructor(context: T, dependents: BrowserServiceHostDependencies<T>) {
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,
});
super(context, {
dialogManager: obsidianSvelteDialogManager,
APIService: dependents.API,
});
}
}
@@ -0,0 +1,33 @@
import { BrowserServiceHub, type BrowserServiceHost } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
import { BrowserConfirm } from "./BrowserConfirm";
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
export type LiveSyncBrowserServiceHubOptions<T extends ServiceContext> = {
context?: T;
openKeyValueDatabase?: KeyValueDatabaseFactory;
};
function createLiveSyncBrowserHost<T extends ServiceContext>(): BrowserServiceHost<T> {
return {
createAPI(context) {
return new BrowserAPIService(context, {
confirm: new BrowserConfirm(context),
});
},
createUI(context, dependencies) {
return new LiveSyncBrowserUIService(context, dependencies);
},
};
}
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
options: LiveSyncBrowserServiceHubOptions<T> = {}
): BrowserServiceHub<T> {
return new BrowserServiceHub<T>({
...options,
host: createLiveSyncBrowserHost<T>(),
});
}
@@ -0,0 +1,28 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { describe, expect, it } from "vitest";
import {
observeServiceComposition,
observeServiceContext,
SERVICE_CONTEXT_MEMBERS,
} from "../../../test/contracts/serviceContext";
import { createLiveSyncBrowserServiceHub } from "./createLiveSyncBrowserServiceHub";
describe("LiveSync browser service context contract", () => {
it("preserves one injected context and its API results throughout the Webapp composition", () => {
const context = createServiceContext({
translate: (key) => `webapp:${key}`,
});
const hub = createLiveSyncBrowserServiceHub({ context });
expect(observeServiceContext(context, "message")).toEqual({
translation: "webapp:message",
receivedEvents: ["context-contract-event"],
});
const composition = observeServiceComposition(hub, context);
expect(composition.hubUsesExpectedContext).toBe(true);
expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual(
[]
);
});
});
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { MenuItem } from "../BrowserMenu";
type Props = {
item: MenuItem;
closeMenu: () => void;
};
const { item = $bindable(), closeMenu }: Props = $props();
function handleCommit(event: MouseEvent | KeyboardEvent) {
event.preventDefault();
try {
if (item.handler) {
item.handler?.();
}
} catch (ex) {
console.error(ex);
}
closeMenu();
}
const icons = {
checkmark: "✓",
} as { [key: string]: string };
function renderIcon(item: MenuItem) {
if (item.icon && item.icon in icons) {
return icons[item.icon] ?? item.icon;
} else if (item.icon !== undefined) {
return "";
}
return "";
}
</script>
<li>
<span class="icon">{renderIcon(item)}</span>
<label for=""
><!-- svelte-ignore a11y_invalid_attribute -->
<a onclick={handleCommit} onkeydown={handleCommit} role="button" tabindex="0" href="#">{item.title}</a></label
>
</li>
<style>
span.icon {
display: inline-block;
min-width: 1.5em;
text-align: center;
}
li {
list-style: none;
padding: 0.5em 1em;
}
</style>
@@ -0,0 +1,10 @@
<script lang="ts">
import type { MenuSeparator } from "../BrowserMenu";
type Props = {
item: MenuSeparator;
};
const { item = $bindable() }: Props = $props();
</script>
<hr />
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import type { Menu, MenuItem, MenuSeparator } from "../BrowserMenu";
import MenuItemView from "./MenuItemView.svelte";
import MenuSeparatorView from "./MenuSeparatorView.svelte";
type Props = {
items: (MenuItem | MenuSeparator)[];
closeMenu: () => void;
};
const { items = $bindable(), closeMenu }: Props = $props();
function handleKey(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
closeMenu();
}
}
</script>
<popup>
<nav>
{#each items as item}
{#if item.type === "separator"}
<MenuSeparatorView {item} />
{:else if item.type === "item"}
<MenuItemView item={item as MenuItem} {closeMenu} />
{/if}
{/each}
</nav>
</popup>
<div class="background" onclick={() => closeMenu()} onkeydown={handleKey} role="none"></div>
<style>
popup {
z-index: 1000;
position: fixed;
background: rgba(255, 255, 255, 0.8);
max-width: 70vw;
max-height: 80vh;
margin: auto;
top: 0;
left: 0;
right: 0;
bottom: 0;
backdrop-filter: blur(5px);
display: flex;
flex-direction: column;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
border: 1px solid var(--background-primary-alt);
justify-content: space-between;
width: fit-content;
height: fit-content;
}
/* popup header {
background: var(--background-primary);
color: var(--text-normal);
padding: 1em;
border-bottom: 1px solid var(--background-primary-alt);
font: size 1.4em;
}
popup article {
padding: 1em;
display: flex;
justify-content: center;
overflow-y: auto;
}
popup article .msg {
white-space: pre-wrap;
}
popup .buttons {
border-top: 1px solid var(--background-primary-alt);
display: flex;
justify-content: center;
align-items: center;
padding: 1em;
}
popup .buttons button {
margin: 0 0.5em;
background-color: var(--background-primary-alt);
} */
popup ~ .background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.125);
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { renderMessageMarkdown } from "./renderMessageMarkdown";
type Props = {
title: string;
message: string;
buttons: string[];
commit: (button: string) => void;
};
let { title, message, buttons, commit }: Props = $props();
const renderedMessage = $derived(renderMessageMarkdown(message));
function handleEsc(event: KeyboardEvent) {
if (event.key === "Escape") {
commit("");
}
}
</script>
<popup>
<header>{title}</header>
<article><div class="msg">{@html renderedMessage}</div></article>
<div class="buttons">
{#each buttons as button}
<button onclick={() => commit(button)}>{button}</button>
{/each}
</div>
</popup>
<div class="background" onclick={() => commit("")} onkeydown={handleEsc} role="none"></div>
<style>
popup {
z-index: 1000;
position: fixed;
background: rgba(255, 255, 255, 0.8);
max-width: 70vw;
max-height: 80vh;
margin: auto;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-width: 50vw;
min-height: 50vh;
backdrop-filter: blur(5px);
display: flex;
flex-direction: column;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
border: 1px solid var(--background-primary-alt);
justify-content: space-between;
}
popup header {
background: var(--background-primary);
color: var(--text-normal);
padding: 1em;
border-bottom: 1px solid var(--background-primary-alt);
font: size 1.4em;
}
popup article {
padding: 1em;
display: flex;
justify-content: center;
overflow-y: auto;
}
popup article .msg {
width: 100%;
line-height: 1.5;
overflow-wrap: anywhere;
}
popup article .msg :global(:first-child) {
margin-top: 0;
}
popup article .msg :global(:last-child) {
margin-bottom: 0;
}
popup article .msg :global(pre) {
overflow-x: auto;
padding: 0.75em;
border-radius: 4px;
background: var(--background-secondary);
}
popup article .msg :global(code) {
font-family: var(--font-monospace);
}
popup article .msg :global(blockquote) {
margin: 0;
padding-left: 1em;
border-left: 3px solid var(--background-modifier-border);
color: var(--text-muted);
}
popup article .msg :global(ul),
popup article .msg :global(ol) {
padding-left: 1.5em;
}
popup article .msg :global(table) {
width: 100%;
border-collapse: collapse;
}
popup article .msg :global(th),
popup article .msg :global(td) {
padding: 0.4em 0.6em;
border: 1px solid var(--background-modifier-border);
}
popup article .msg :global(a) {
color: var(--text-accent);
}
popup .buttons {
border-top: 1px solid var(--background-primary-alt);
display: flex;
justify-content: center;
align-items: center;
padding: 1em;
}
popup .buttons button {
margin: 0 0.5em;
background-color: var(--background-primary-alt);
}
popup ~ .background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.125);
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
type Props = {
title: string;
message: string;
initialText?: string;
placeholder?: string;
isPassword?: boolean;
commit: (text: string | false) => void;
};
const { title, message, commit, initialText, placeholder, isPassword }: Props = $props();
let text = $state(initialText || "");
let type = $state(isPassword ? "password" : "text");
function cancel() {
commit(false);
}
function handleKey(event: KeyboardEvent) {
if (event.key === "Escape") {
handleCancel(event);
} else if (event.key === "Enter") {
handleCommit(event);
}
}
function handleCancel(event: KeyboardEvent | MouseEvent) {
cancel();
event.preventDefault();
}
function handleCommit(event: KeyboardEvent | MouseEvent) {
commit(text);
event.preventDefault();
}
let textEl: HTMLInputElement;
$effect(() => {
textEl.focus();
});
</script>
<popup>
<header>{title}</header>
<article>
<div class="msg">{message}</div>
<div class="input">
<input
bind:this={textEl}
{type}
bind:value={text}
{placeholder}
onkeydown={handleKey}
onkeyup={handleKey}
/>
</div>
</article>
<div class="buttons">
<button onclick={handleCommit}>OK</button>
<button onclick={handleCancel}>Cancel</button>
</div>
</popup>
<div class="background" onclick={handleCancel} onkeydown={handleKey} role="none"></div>
<style>
popup {
z-index: 1000;
position: fixed;
background: rgba(255, 255, 255, 0.8);
max-width: 70vw;
max-height: 80vh;
margin: auto;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-width: 50vw;
min-height: 50vh;
backdrop-filter: blur(5px);
display: flex;
flex-direction: column;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
border: 1px solid var(--background-primary-alt);
justify-content: space-between;
}
popup header {
background: var(--background-primary);
color: var(--text-normal);
padding: 1em;
border-bottom: 1px solid var(--background-primary-alt);
font: size 1.4em;
}
popup article {
align-items: center;
padding: 1em;
display: flex;
justify-content: center;
flex-direction: column;
}
popup article .msg {
overflow-y: auto;
white-space: pre-wrap;
}
popup .buttons {
border-top: 1px solid var(--background-primary-alt);
display: flex;
justify-content: center;
align-items: center;
padding: 1em;
}
popup .buttons button {
margin: 0 0.5em;
background-color: var(--background-primary-alt);
}
popup ~ .background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.125);
}
</style>
@@ -0,0 +1,21 @@
import MarkdownIt from "markdown-it";
const markdownRenderer = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
});
const defaultLinkOpenRenderer =
markdownRenderer.renderer.rules.link_open ??
((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) => {
tokens[idx].attrSet("target", "_blank");
tokens[idx].attrSet("rel", "noopener noreferrer");
return defaultLinkOpenRenderer(tokens, idx, options, env, self);
};
export function renderMessageMarkdown(message: string): string {
return markdownRenderer.render(message);
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { renderMessageMarkdown } from "./renderMessageMarkdown";
describe("renderMessageMarkdown", () => {
it("renders basic markdown features used by browser dialogues", () => {
const html = renderMessageMarkdown("# Title\n\n| left | right |\n| --- | --- |\n| a | b |\n");
expect(html).toContain("<h1>Title</h1>");
expect(html).toContain("<table>");
expect(html).toContain("<td>a</td>");
});
it("escapes inline HTML instead of rendering it", () => {
const html = renderMessageMarkdown("Before<script>alert('xss')</script>After");
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;alert('xss')&lt;/script&gt;");
});
it("opens Markdown links safely in a new tab", () => {
const html = renderMessageMarkdown("[docs](https://example.com)");
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});
});