@zkov/pi-md-viewer 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ import { discover, portRangeFromEnv, sendFiles, shutdownInstance } from "./disco
9
9
  import { FileRegistry } from "./registry.js";
10
10
  import { MarkdownRenderer } from "./renderer.js";
11
11
  import { ViewerServer } from "./server.js";
12
- import { launchViewer } from "./viewer-launcher.js";
12
+ import { launchViewer, runPlaywrightViewerHost } from "./viewer-launcher.js";
13
13
  const STARTUP_TIMEOUT_MS = 5000;
14
14
  function operationResult(payload) {
15
15
  return { url: null, added: [], rejected: [], viewerClients: 0, ...payload };
@@ -50,11 +50,20 @@ function parseArgs(argv, cwd) {
50
50
  parsed.viewer = requireValue(argv, ++index, arg);
51
51
  else if (arg === "--viewer-command")
52
52
  parsed.viewerCommand = requireValue(argv, ++index, arg);
53
+ else if (arg === "--playwright-viewer")
54
+ parsed.playwrightViewer = requireValue(argv, ++index, arg);
55
+ else if (arg === "--browser")
56
+ parsed.browser = parseBrowser(requireValue(argv, ++index, arg));
53
57
  else
54
58
  parsed.paths.push(arg);
55
59
  }
56
60
  return parsed;
57
61
  }
62
+ function parseBrowser(value) {
63
+ if (value === "chromium" || value === "firefox" || value === "webkit")
64
+ return value;
65
+ throw new Error("--browser must be chromium, firefox, or webkit");
66
+ }
58
67
  function requireValue(argv, index, flag) {
59
68
  const value = argv[index];
60
69
  if (value === undefined)
@@ -78,6 +87,10 @@ export async function main(argv, io = {}) {
78
87
  let args;
79
88
  try {
80
89
  args = parseArgs(argv, process.cwd());
90
+ if (args.playwrightViewer) {
91
+ await runPlaywrightViewerHost({ url: args.playwrightViewer, browserName: args.browser ?? "chromium", env });
92
+ return 0;
93
+ }
81
94
  if (args.serve)
82
95
  return await serve(args.port, args.paths);
83
96
  const result = await operate(args, env);
@@ -124,7 +124,7 @@ export class ViewerServer {
124
124
  const template = readFileSync(path.join(legacyAssetRoot, "templates", "viewer.html"), "utf8");
125
125
  this.sendBytes(response, 200, Buffer.from(template.replace("{{CSRF_TOKEN}}", this.csrfToken)), "text/html; charset=utf-8");
126
126
  }
127
- else if (["/static/viewer.css", "/static/viewer.js", "/static/viewer-actions.js"].includes(parsed.pathname)) {
127
+ else if (["/static/viewer.css", "/static/viewer.js", "/static/viewer-actions.js", "/static/side-aura.svg"].includes(parsed.pathname)) {
128
128
  await this.serveAsset(parsed.pathname.replace("/static/", ""), response);
129
129
  }
130
130
  else if (parsed.pathname === "/api/v1/status") {
@@ -281,7 +281,11 @@ export class ViewerServer {
281
281
  const file = path.join(legacyAssetRoot, "static", name);
282
282
  try {
283
283
  const stat = statSync(file);
284
- const type = name.endsWith(".css") ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
284
+ const type = name.endsWith(".css")
285
+ ? "text/css; charset=utf-8"
286
+ : name.endsWith(".svg")
287
+ ? "image/svg+xml; charset=utf-8"
288
+ : "application/javascript; charset=utf-8";
285
289
  response.writeHead(200, { "Content-Type": type, "Content-Length": stat.size });
286
290
  createReadStream(file).pipe(response);
287
291
  }
@@ -1,4 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { headedPlaywrightUnavailableReason, systemOpenCommand } from "./platform.js";
3
6
  import { expandViewerCommand, parseViewerCommand } from "./viewer-command.js";
4
7
  async function defaultSpawnDetached(command, args) {
@@ -51,37 +54,129 @@ function playwrightLaunchOptions(browserName, platform, env) {
51
54
  return options;
52
55
  }
53
56
  async function launchPlaywright(request) {
54
- const reason = headedPlaywrightUnavailableReason(request.platform, request.env);
57
+ const browserName = request.selection.config.playwright.browser;
58
+ if (!request.importPlaywright) {
59
+ await launchDetachedPlaywrightHost(request.url, browserName, request.spawnPlaywrightHost ?? defaultSpawnPlaywrightHost);
60
+ return;
61
+ }
62
+ await openPlaywrightPage({
63
+ url: request.url,
64
+ browserName,
65
+ platform: request.platform,
66
+ env: request.env,
67
+ importPlaywright: request.importPlaywright,
68
+ });
69
+ }
70
+ async function openPlaywrightPage(options) {
71
+ const reason = headedPlaywrightUnavailableReason(options.platform, options.env);
55
72
  if (reason === "unsupported-platform")
56
73
  throw new Error('Viewer "playwright" is not available on this platform');
57
74
  if (reason)
58
75
  throw new Error(`Viewer "playwright" is not available: ${reason}`);
59
- const browserName = request.selection.config.playwright.browser;
60
76
  let imported;
61
77
  try {
62
- imported = request.importPlaywright ? await request.importPlaywright() : await import("playwright");
78
+ imported = options.importPlaywright ? await options.importPlaywright() : await import("playwright");
63
79
  }
64
80
  catch (error) {
65
81
  if (isMissingPlaywrightPackage(error))
66
- throw new Error(playwrightInstallHint(browserName));
82
+ throw new Error(playwrightInstallHint(options.browserName));
67
83
  throw error;
68
84
  }
69
85
  const api = imported;
70
- const browserType = api[browserName];
86
+ const browserType = api[options.browserName];
71
87
  if (!browserType)
72
- throw new Error(`Playwright browser "${browserName}" is unavailable`);
88
+ throw new Error(`Playwright browser "${options.browserName}" is unavailable`);
73
89
  let browser;
74
90
  try {
75
- browser = await browserType.launch(playwrightLaunchOptions(browserName, request.platform, request.env));
91
+ browser = await browserType.launch(playwrightLaunchOptions(options.browserName, options.platform, options.env));
76
92
  }
77
93
  catch (error) {
78
94
  if (isMissingPlaywrightBrowser(error))
79
- throw new Error(playwrightBrowserInstallHint(browserName));
95
+ throw new Error(playwrightBrowserInstallHint(options.browserName));
80
96
  throw error;
81
97
  }
82
98
  const context = await browser.newContext({ viewport: null });
83
99
  const page = await context.newPage();
84
- await page.goto(request.url);
100
+ await page.goto(options.url);
101
+ return browser;
102
+ }
103
+ async function launchDetachedPlaywrightHost(url, browserName, spawnPlaywrightHost) {
104
+ const result = await spawnPlaywrightHost(["--playwright-viewer", url, "--browser", browserName]);
105
+ if (!result.ok)
106
+ throw new Error(result.message);
107
+ }
108
+ function defaultSpawnPlaywrightHost(args) {
109
+ return new Promise((resolve, reject) => {
110
+ const child = spawn(process.execPath, [entrypointPath(), ...args], {
111
+ detached: true,
112
+ stdio: ["ignore", "pipe", "pipe", "ignore"],
113
+ windowsHide: true,
114
+ env: { ...process.env, NODE_NO_WARNINGS: "1" },
115
+ });
116
+ let stdout = "";
117
+ let stderr = "";
118
+ let settled = false;
119
+ const timeout = setTimeout(() => finish({ ok: false, message: "Timed out waiting for Playwright viewer to open" }), 10_000);
120
+ function finish(result) {
121
+ if (settled)
122
+ return;
123
+ settled = true;
124
+ clearTimeout(timeout);
125
+ child.stdout?.destroy();
126
+ child.stderr?.destroy();
127
+ if (result.ok)
128
+ child.unref();
129
+ resolve(result);
130
+ }
131
+ child.once("error", reject);
132
+ child.once("exit", (code) => {
133
+ if (!settled)
134
+ finish({ ok: false, message: stderr.trim() || `Playwright viewer exited with status ${code ?? "unknown"}` });
135
+ });
136
+ child.stdout?.setEncoding("utf8");
137
+ child.stdout?.on("data", (chunk) => {
138
+ stdout += chunk;
139
+ const newline = stdout.indexOf("\n");
140
+ if (newline === -1)
141
+ return;
142
+ const line = stdout.slice(0, newline).trim();
143
+ try {
144
+ const payload = JSON.parse(line);
145
+ finish(payload.ok === true ? { ok: true } : { ok: false, message: typeof payload.message === "string" ? payload.message : "Playwright viewer failed to open" });
146
+ }
147
+ catch (error) {
148
+ finish({ ok: false, message: `Invalid Playwright viewer response: ${error instanceof Error ? error.message : String(error)}` });
149
+ }
150
+ });
151
+ child.stderr?.setEncoding("utf8");
152
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
153
+ });
154
+ }
155
+ function entrypointPath() {
156
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
157
+ const dist = path.join(root, "dist", "src-ts", "main.js");
158
+ if (existsSync(dist))
159
+ return dist;
160
+ return fileURLToPath(import.meta.url).replace(/viewer-launcher\.(ts|js)$/, "main.js");
161
+ }
162
+ export async function runPlaywrightViewerHost(options) {
163
+ const output = options.stdout ?? console.log;
164
+ try {
165
+ const browser = await openPlaywrightPage({
166
+ url: options.url,
167
+ browserName: options.browserName,
168
+ platform: options.platform ?? process.platform,
169
+ env: options.env ?? process.env,
170
+ importPlaywright: options.importPlaywright,
171
+ });
172
+ output(JSON.stringify({ ok: true }));
173
+ await new Promise((resolve) => {
174
+ browser.on?.("disconnected", resolve);
175
+ });
176
+ }
177
+ catch (error) {
178
+ output(JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) }));
179
+ }
85
180
  }
86
181
  export async function launchViewer(request) {
87
182
  const spawnDetached = request.spawnDetached ?? defaultSpawnDetached;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zkov/pi-md-viewer",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local loopback Markdown viewer tool for pi with configurable browser launchers",
5
5
  "author": "Zkov <zkov@yandex.ru>",
6
6
  "license": "MIT",
@@ -0,0 +1,46 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none">
2
+ <defs>
3
+ <linearGradient id="left-main" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0" stop-color="#152138" />
5
+ <stop offset=".38" stop-color="#1b2945" />
6
+ <stop offset=".68" stop-color="#181f3c" />
7
+ <stop offset="1" stop-color="#152138" />
8
+ </linearGradient>
9
+ <linearGradient id="left-accent" x1="0" y1="0" x2="0" y2="1">
10
+ <stop offset="0" stop-color="#295b7e" />
11
+ <stop offset=".44" stop-color="#4c4482" />
12
+ <stop offset="1" stop-color="#295b7e" />
13
+ </linearGradient>
14
+ <linearGradient id="right-main" x1="0" y1="0" x2="0" y2="1">
15
+ <stop offset="0" stop-color="#152138" />
16
+ <stop offset=".32" stop-color="#181f3c" />
17
+ <stop offset=".72" stop-color="#1b2945" />
18
+ <stop offset="1" stop-color="#152138" />
19
+ </linearGradient>
20
+ <linearGradient id="right-accent" x1="0" y1="0" x2="0" y2="1">
21
+ <stop offset="0" stop-color="#4c4482" />
22
+ <stop offset=".52" stop-color="#295b7e" />
23
+ <stop offset="1" stop-color="#473e7e" />
24
+ </linearGradient>
25
+ <linearGradient id="purple-wash" x1="0" y1="0" x2="0" y2="1">
26
+ <stop offset="0" stop-color="#0a0d12" stop-opacity="0" />
27
+ <stop offset=".34" stop-color="#5b4c96" stop-opacity=".028" />
28
+ <stop offset=".66" stop-color="#0a0d12" stop-opacity="0" />
29
+ <stop offset="1" stop-color="#473e7e" stop-opacity=".022" />
30
+ </linearGradient>
31
+ </defs>
32
+
33
+ <rect width="100" height="100" fill="url(#purple-wash)" />
34
+
35
+ <g fill="none" stroke-linecap="round" stroke-linejoin="round">
36
+ <path d="M 4 -6 C 1 18 -1 42 1 64 C 3 80 0 93 1 106" stroke="url(#left-main)" stroke-width="28" stroke-opacity=".20" />
37
+ <path d="M 4 -6 C 1 18 -1 42 1 64 C 3 80 0 93 1 106" stroke="url(#left-main)" stroke-width="18" stroke-opacity=".34" />
38
+ <path d="M 4 -6 C 1 18 -1 42 1 64 C 3 80 0 93 1 106" stroke="url(#left-accent)" stroke-width="9" stroke-opacity=".13" />
39
+ <path d="M 2 -4 C 0 24 0 48 2 72 C 3 84 1 96 2 104" stroke="#152138" stroke-width="5" stroke-opacity=".22" />
40
+
41
+ <path d="M 96 -6 C 99 20 101 45 99 67 C 97 82 100 95 99 106" stroke="url(#right-main)" stroke-width="28" stroke-opacity=".20" />
42
+ <path d="M 96 -6 C 99 20 101 45 99 67 C 97 82 100 95 99 106" stroke="url(#right-main)" stroke-width="18" stroke-opacity=".34" />
43
+ <path d="M 96 -6 C 99 20 101 45 99 67 C 97 82 100 95 99 106" stroke="url(#right-accent)" stroke-width="9" stroke-opacity=".13" />
44
+ <path d="M 98 -4 C 100 23 100 50 98 74 C 97 86 99 97 98 104" stroke="#152138" stroke-width="5" stroke-opacity=".22" />
45
+ </g>
46
+ </svg>
@@ -1,6 +1,11 @@
1
- export async function unloadAndMaybeClose({ remove, reload, close }) {
1
+ export async function unloadAndReload({ remove, reload }) {
2
2
  await remove();
3
- const remaining = await reload();
4
- if (remaining === 0) await close();
5
- return remaining;
3
+ return reload();
4
+ }
5
+
6
+ export function selectNextActiveFile(files, removedId, activeId) {
7
+ if (removedId !== activeId) return activeId;
8
+ const index = files.findIndex((file) => file.id === removedId);
9
+ if (index === -1) return activeId;
10
+ return files[index + 1]?.id ?? files[index - 1]?.id ?? null;
6
11
  }
@@ -10,13 +10,26 @@
10
10
  --line: #263243;
11
11
  --code: #090c11;
12
12
  --danger: #ff8d9b;
13
+ --linear-side-aura-background:
14
+ linear-gradient(90deg, rgba(21, 33, 56, .82) -12%, rgba(21, 33, 56, .42) 2%, rgba(76, 68, 130, .08) 10%, transparent 16%),
15
+ linear-gradient(90deg, rgba(41, 91, 126, .26) -8%, rgba(41, 91, 126, .10) 6%, transparent 14%),
16
+ linear-gradient(90deg, transparent 0%, transparent 84%, rgba(76, 68, 130, .08) 90%, rgba(21, 33, 56, .42) 99%, rgba(21, 33, 56, .82) 112%),
17
+ linear-gradient(90deg, transparent 0%, transparent 86%, rgba(41, 91, 126, .10) 94%, rgba(41, 91, 126, .26) 108%),
18
+ var(--bg);
19
+ --svg-side-aura-background: url("/static/side-aura.svg"), var(--bg);
13
20
  }
14
21
 
15
22
  * { box-sizing: border-box; }
16
- html { scroll-behavior: smooth; }
23
+ html {
24
+ scroll-behavior: smooth;
25
+ background: var(--bg);
26
+ }
17
27
  body {
28
+ min-height: 100vh;
18
29
  margin: 0;
19
- background: radial-gradient(circle at 85% 0%, #152138 0, transparent 34rem), var(--bg);
30
+ background: var(--svg-side-aura-background);
31
+ background-repeat: no-repeat;
32
+ background-size: 100% 100%;
20
33
  color: var(--text);
21
34
  font: 16px/1.72 system-ui, -apple-system, "Segoe UI", sans-serif;
22
35
  }
@@ -82,10 +95,22 @@ button:disabled { cursor: default; opacity: .45; }
82
95
  }
83
96
  .icon-button { padding: 2px 8px; font-size: 18px; }
84
97
  .danger { color: var(--danger); }
98
+ .file-row {
99
+ display: flex;
100
+ align-items: stretch;
101
+ gap: 4px;
102
+ margin: 4px 0;
103
+ border: 1px solid transparent;
104
+ border-radius: 8px;
105
+ }
106
+ .file-row.active {
107
+ border-color: rgba(124, 196, 255, .3);
108
+ background: rgba(124, 196, 255, .09);
109
+ }
85
110
  .file-button {
86
111
  display: block;
87
- width: 100%;
88
- margin: 4px 0;
112
+ min-width: 0;
113
+ flex: 1;
89
114
  padding: 9px 10px;
90
115
  overflow: hidden;
91
116
  border-color: transparent;
@@ -95,10 +120,20 @@ button:disabled { cursor: default; opacity: .45; }
95
120
  white-space: nowrap;
96
121
  }
97
122
  .file-button small { display: block; color: var(--muted); }
98
- .file-button.active {
99
- border-color: rgba(124, 196, 255, .3);
100
- background: rgba(124, 196, 255, .09);
101
- color: #fff;
123
+ .file-row.active .file-button { color: #fff; }
124
+ .file-actions {
125
+ display: flex;
126
+ align-items: center;
127
+ gap: 2px;
128
+ padding-right: 4px;
129
+ }
130
+ .file-action {
131
+ min-width: 28px;
132
+ padding: 3px 6px;
133
+ border-color: transparent;
134
+ background: transparent;
135
+ font-size: 16px;
136
+ line-height: 1;
102
137
  }
103
138
  main { min-width: 0; padding-top: 4px; }
104
139
  .document-toolbar {
@@ -212,22 +247,26 @@ hr { margin: 40px 0; border: 0; border-top: 1px solid var(--line); }
212
247
  @media (max-width: 980px) {
213
248
  .mobile-bar {
214
249
  position: sticky;
215
- z-index: 20;
250
+ z-index: 40;
216
251
  top: 0;
217
252
  display: flex;
218
253
  align-items: center;
219
254
  justify-content: space-between;
255
+ min-height: 49px;
220
256
  padding: 10px 16px;
221
257
  border-bottom: 1px solid var(--line);
222
258
  background: rgba(10, 13, 18, .94);
223
259
  }
224
- .layout { display: block; padding: 20px 18px 80px; }
260
+ .layout { display: block; padding: 28px 18px 80px; }
225
261
  .files-panel {
226
262
  display: none;
227
- position: relative;
228
- top: auto;
229
- max-height: 48vh;
230
- margin: 0 auto 28px;
263
+ position: fixed;
264
+ z-index: 35;
265
+ top: 57px;
266
+ left: 18px;
267
+ right: 18px;
268
+ max-height: calc(100vh - 73px);
269
+ margin: 0 auto;
231
270
  max-width: 820px;
232
271
  }
233
272
  .files-panel.open { display: block; }
@@ -1,4 +1,4 @@
1
- import { unloadAndMaybeClose } from "/static/viewer-actions.js";
1
+ import { selectNextActiveFile, unloadAndReload } from "/static/viewer-actions.js";
2
2
 
3
3
  const csrf = document.querySelector('meta[name="csrf-token"]').content;
4
4
  const clientId = crypto.randomUUID();
@@ -16,9 +16,6 @@ const elements = {
16
16
  status: document.getElementById("connection-status"),
17
17
  sidebar: document.getElementById("sidebar"),
18
18
  toggle: document.getElementById("sidebar-toggle"),
19
- refresh: document.getElementById("refresh-file"),
20
- unload: document.getElementById("unload-file"),
21
- close: document.getElementById("close-viewer"),
22
19
  };
23
20
 
24
21
  async function api(path, options = {}) {
@@ -53,9 +50,12 @@ function notify(message) {
53
50
  function renderFileList() {
54
51
  elements.fileList.replaceChildren();
55
52
  for (const file of state.files) {
53
+ const row = document.createElement("div");
54
+ row.className = `file-row${file.id === state.activeId ? " active" : ""}`;
55
+
56
56
  const button = document.createElement("button");
57
57
  button.type = "button";
58
- button.className = `file-button${file.id === state.activeId ? " active" : ""}`;
58
+ button.className = "file-button";
59
59
  button.title = file.path;
60
60
  button.append(document.createTextNode(file.displayName));
61
61
  if (file.parentLabel) {
@@ -64,7 +64,35 @@ function renderFileList() {
64
64
  button.append(parent);
65
65
  }
66
66
  button.addEventListener("click", () => selectFile(file.id, true));
67
- elements.fileList.append(button);
67
+
68
+ const actions = document.createElement("div");
69
+ actions.className = "file-actions";
70
+
71
+ const refresh = document.createElement("button");
72
+ refresh.type = "button";
73
+ refresh.className = "file-action";
74
+ refresh.title = "Обновить документ";
75
+ refresh.setAttribute("aria-label", `Обновить ${file.displayName}`);
76
+ refresh.textContent = "⟳";
77
+ refresh.addEventListener("click", (event) => {
78
+ event.stopPropagation();
79
+ selectFile(file.id, true).catch(showError);
80
+ });
81
+
82
+ const unload = document.createElement("button");
83
+ unload.type = "button";
84
+ unload.className = "file-action danger";
85
+ unload.title = "Выгрузить документ";
86
+ unload.setAttribute("aria-label", `Выгрузить ${file.displayName}`);
87
+ unload.textContent = "×";
88
+ unload.addEventListener("click", (event) => {
89
+ event.stopPropagation();
90
+ unloadFile(file.id).catch(showError);
91
+ });
92
+
93
+ actions.append(refresh, unload);
94
+ row.append(button, actions);
95
+ elements.fileList.append(row);
68
96
  }
69
97
  }
70
98
 
@@ -143,30 +171,31 @@ async function loadFiles({ announce = false } = {}) {
143
171
  }
144
172
  renderFileList();
145
173
  if (state.activeId) await selectFile(state.activeId, state.pinned);
174
+ else showEmptyState();
146
175
  if (announce && state.files.some((file) => !previous.has(file.id))) notify("Добавлены новые документы");
147
176
  return state.files.length;
148
177
  }
149
178
 
150
- async function unloadActive() {
151
- if (!state.activeId) return;
152
- const fileId = state.activeId;
153
- await unloadAndMaybeClose({
179
+ function showEmptyState() {
180
+ elements.title.textContent = "Markdown Viewer";
181
+ elements.source.textContent = "";
182
+ state.revision = null;
183
+ elements.document.innerHTML = '<p class="empty-state">Добавьте Markdown-файл командой <code>mdview файл.md</code>.</p>';
184
+ renderToc([]);
185
+ }
186
+
187
+ async function unloadFile(fileId) {
188
+ const nextActiveId = selectNextActiveFile(state.files, fileId, state.activeId);
189
+ await unloadAndReload({
154
190
  remove: () => api(`/api/v1/files/${encodeURIComponent(fileId)}`, { method: "DELETE" }),
155
191
  reload: async () => {
156
- state.activeId = null;
192
+ state.activeId = nextActiveId;
157
193
  state.pinned = false;
158
194
  return loadFiles();
159
195
  },
160
- close: closeViewer,
161
196
  });
162
197
  }
163
198
 
164
- async function closeViewer() {
165
- await api("/api/v1/shutdown", { method: "POST", body: {} });
166
- elements.status.textContent = "Viewer закрыт";
167
- window.close();
168
- }
169
-
170
199
  function connectEvents() {
171
200
  const events = new EventSource(`/api/v1/events?clientId=${encodeURIComponent(clientId)}`);
172
201
  events.addEventListener("open", () => { elements.status.textContent = "Подключено"; });
@@ -175,7 +204,6 @@ function connectEvents() {
175
204
  events.addEventListener("server_closing", () => {
176
205
  elements.status.textContent = "Viewer закрывается";
177
206
  events.close();
178
- window.close();
179
207
  });
180
208
  events.addEventListener("error", () => { elements.status.textContent = "Переподключение…"; });
181
209
  }
@@ -185,9 +213,6 @@ function updateProgress() {
185
213
  elements.progress.style.width = `${maximum > 0 ? (scrollY / maximum) * 100 : 0}%`;
186
214
  }
187
215
 
188
- elements.refresh.addEventListener("click", () => state.activeId && selectFile(state.activeId, state.pinned));
189
- elements.unload.addEventListener("click", () => unloadActive().catch(showError));
190
- elements.close.addEventListener("click", () => closeViewer().catch(showError));
191
216
  elements.toggle.addEventListener("click", () => {
192
217
  const open = elements.sidebar.classList.toggle("open");
193
218
  elements.toggle.setAttribute("aria-expanded", String(open));
@@ -18,7 +18,6 @@
18
18
  <div class="brand">pi · markdown viewer</div>
19
19
  <div class="panel-heading">
20
20
  <span>Файлы</span>
21
- <button id="close-viewer" class="icon-button danger" type="button" title="Закрыть viewer">×</button>
22
21
  </div>
23
22
  <nav id="file-list" aria-label="Открытые Markdown-файлы"></nav>
24
23
  </aside>
@@ -28,10 +27,6 @@
28
27
  <h1 id="document-title">Markdown Viewer</h1>
29
28
  <div id="source-path" class="source-path"></div>
30
29
  </div>
31
- <div class="toolbar-actions">
32
- <button id="refresh-file" type="button">Обновить</button>
33
- <button id="unload-file" type="button">Выгрузить</button>
34
- </div>
35
30
  </div>
36
31
  <div id="notice" class="notice" role="status" hidden></div>
37
32
  <div id="error" class="error" role="alert" hidden></div>