@zkov/pi-md-viewer 0.1.0 → 0.1.2

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.
@@ -24,7 +24,11 @@ export function buildInvocation(cwd, extensionFile, input, options = {}) {
24
24
  const platform = options.platform ?? process.platform;
25
25
  const pathForCwd = platform === "win32" ? path.win32 : path.posix;
26
26
  const extensionPath = fileURLToPath(extensionFile);
27
- const launcher = path.resolve(path.dirname(extensionPath), "..", "bin", "mdview.js");
27
+ const extensionDir = path.dirname(extensionPath);
28
+ const packageRoot = path.basename(path.dirname(extensionDir)) === "dist"
29
+ ? path.resolve(extensionDir, "..", "..")
30
+ : path.resolve(extensionDir, "..");
31
+ const launcher = path.join(packageRoot, "bin", "mdview.js");
28
32
  const resolvedPaths = input.paths.map((supplied) => {
29
33
  const normalized = supplied.startsWith("@") ? supplied.slice(1) : supplied;
30
34
  if (!normalized)
@@ -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
  }
@@ -53,7 +53,11 @@ export function buildInvocation(
53
53
  const platform = options.platform ?? process.platform;
54
54
  const pathForCwd = platform === "win32" ? path.win32 : path.posix;
55
55
  const extensionPath = fileURLToPath(extensionFile);
56
- const launcher = path.resolve(path.dirname(extensionPath), "..", "bin", "mdview.js");
56
+ const extensionDir = path.dirname(extensionPath);
57
+ const packageRoot = path.basename(path.dirname(extensionDir)) === "dist"
58
+ ? path.resolve(extensionDir, "..", "..")
59
+ : path.resolve(extensionDir, "..");
60
+ const launcher = path.join(packageRoot, "bin", "mdview.js");
57
61
  const resolvedPaths = input.paths.map((supplied) => {
58
62
  const normalized = supplied.startsWith("@") ? supplied.slice(1) : supplied;
59
63
  if (!normalized) throw new Error("show_markdown path cannot be empty");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zkov/pi-md-viewer",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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 {
@@ -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>