omni-viewer-core 0.11.0 → 0.12.0

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.
@@ -136,7 +136,6 @@ export const CATALOG_EN = {
136
136
  'latex.documentClass': 'Class: {name}', 'latex.sectionCount': '{count} section(s)',
137
137
  'latex.unsupportedEnvironment': '{environment} — not rendered',
138
138
  'latex.unresolvedInput': '\\{command} — external file not resolved',
139
- 'latex.floatCaptionOnly': '{environment} — caption only',
140
139
  'latex.parseFailed': 'This file could not be read as a LaTeX document.',
141
140
  'latex.mathNotInstalled': 'Math renderer not installed — formulas are shown as TeX',
142
141
  'latex.mathFailed': 'This formula could not be rendered; its TeX source is shown.',
@@ -159,7 +158,6 @@ export const CATALOG_EN = {
159
158
  'diag.latex.limit-exceeded': 'The LaTeX document exceeds a supported limit.',
160
159
  'diag.latex.depth-exceeded': 'The LaTeX document nests deeper than the supported limit.',
161
160
  'diag.latex.math-limit-exceeded': 'A formula exceeds the supported size and is shown as source.',
162
- 'diag.latex.float-caption-only': 'Only the caption of the {environment} environment is shown.',
163
161
  'archive.title': 'Archive', 'archive.search': 'Search entries', 'archive.entries': '{count} entries',
164
162
  'archive.previewUnavailable': 'Preview unavailable', 'archive.mediaUnsupported': 'Media preview is unsupported.',
165
163
  'archive.encrypted': 'This archive is encrypted.', 'archive.encryptedEntries': 'This archive contains {count} encrypted entries.',
@@ -140,7 +140,6 @@ export const CATALOG_JA = {
140
140
  'latex.documentClass': 'クラス: {name}', 'latex.sectionCount': 'セクション {count} 個',
141
141
  'latex.unsupportedEnvironment': '{environment} — レンダーしません',
142
142
  'latex.unresolvedInput': '\\{command} — 外部ファイルは解決しません',
143
- 'latex.floatCaptionOnly': '{environment} — キャプションのみ',
144
143
  'latex.parseFailed': 'このファイルはLaTeX文書として読み取れませんでした。',
145
144
  'latex.mathNotInstalled': '数式レンダラーが未インストールのため、数式をTeXソースで表示します',
146
145
  'latex.mathFailed': 'この数式をレンダーできなかったため、TeXソースを表示します。',
@@ -163,7 +162,6 @@ export const CATALOG_JA = {
163
162
  'diag.latex.limit-exceeded': 'LaTeX文書が対応上限を超えています。',
164
163
  'diag.latex.depth-exceeded': 'LaTeX文書の入れ子が対応上限より深くなっています。',
165
164
  'diag.latex.math-limit-exceeded': '数式が対応サイズを超えたため、ソースとして表示します。',
166
- 'diag.latex.float-caption-only': '{environment}環境はキャプションのみ表示します。',
167
165
  'archive.title': 'アーカイブ', 'archive.search': '項目を検索', 'archive.entries': '{count}項目',
168
166
  'archive.previewUnavailable': 'プレビューできません', 'archive.mediaUnsupported': 'メディアのプレビューには対応していません。',
169
167
  'archive.encrypted': 'このアーカイブは暗号化されています。', 'archive.encryptedEntries': 'このアーカイブには暗号化された項目が{count}件あります。',
@@ -136,7 +136,6 @@ export const CATALOG_KO = {
136
136
  'latex.documentClass': '클래스: {name}', 'latex.sectionCount': '절 {count}개',
137
137
  'latex.unsupportedEnvironment': '{environment} — 렌더하지 않음',
138
138
  'latex.unresolvedInput': '\\{command} — 외부 파일을 해석하지 않음',
139
- 'latex.floatCaptionOnly': '{environment} — 캡션만 표시',
140
139
  'latex.parseFailed': 'LaTeX 문서로 읽을 수 없는 파일입니다.',
141
140
  'latex.mathNotInstalled': '수식 렌더러가 설치되지 않아 수식을 TeX 원문으로 표시합니다',
142
141
  'latex.mathFailed': '이 수식을 렌더하지 못해 TeX 원문을 표시합니다.',
@@ -159,7 +158,6 @@ export const CATALOG_KO = {
159
158
  'diag.latex.limit-exceeded': 'LaTeX 문서가 지원 한도를 초과합니다.',
160
159
  'diag.latex.depth-exceeded': 'LaTeX 문서의 중첩이 지원 한도보다 깊습니다.',
161
160
  'diag.latex.math-limit-exceeded': '수식이 지원 크기를 초과하여 원문으로 표시합니다.',
162
- 'diag.latex.float-caption-only': '{environment} 환경은 캡션만 표시합니다.',
163
161
  'archive.title': '압축 파일', 'archive.search': '항목 검색', 'archive.entries': '항목 {count}개',
164
162
  'archive.previewUnavailable': '미리보기를 사용할 수 없음', 'archive.mediaUnsupported': '미디어 미리보기는 지원되지 않습니다.',
165
163
  'archive.encrypted': '이 압축 파일은 암호화되어 있습니다.', 'archive.encryptedEntries': '이 압축 파일에는 암호화된 항목이 {count}개 있습니다.',
@@ -133,7 +133,6 @@ export const CATALOG_ZH_CN = {
133
133
  'latex.documentClass': '文档类:{name}', 'latex.sectionCount': '{count} 个章节',
134
134
  'latex.unsupportedEnvironment': '{environment} — 不渲染',
135
135
  'latex.unresolvedInput': '\\{command} — 不解析外部文件',
136
- 'latex.floatCaptionOnly': '{environment} — 仅显示题注',
137
136
  'latex.parseFailed': '无法将此文件作为 LaTeX 文档读取。',
138
137
  'latex.mathNotInstalled': '未安装公式渲染器 — 公式以 TeX 源代码显示',
139
138
  'latex.mathFailed': '无法渲染此公式,改为显示其 TeX 源代码。',
@@ -156,7 +155,6 @@ export const CATALOG_ZH_CN = {
156
155
  'diag.latex.limit-exceeded': 'LaTeX 文档超过支持的限制。',
157
156
  'diag.latex.depth-exceeded': 'LaTeX 文档的嵌套超过支持的深度限制。',
158
157
  'diag.latex.math-limit-exceeded': '公式超过支持的大小,改为显示源代码。',
159
- 'diag.latex.float-caption-only': '{environment} 环境仅显示题注。',
160
158
  'archive.title': '压缩包', 'archive.search': '搜索条目', 'archive.entries': '{count} 个条目',
161
159
  'archive.previewUnavailable': '无法预览', 'archive.mediaUnsupported': '不支持媒体预览。',
162
160
  'archive.encrypted': '此压缩包已加密。', 'archive.encryptedEntries': '此压缩包包含 {count} 个加密条目。',
@@ -107,10 +107,14 @@ export type LatexBlock = {
107
107
  kind: 'verbatim';
108
108
  source: string;
109
109
  span: LatexSpan;
110
- } | {
110
+ }
111
+ /** A `figure`/`table` float. Its body is scanned like any other content —
112
+ * the caption is lifted out for display, not used as a replacement for it. */
113
+ | {
111
114
  kind: 'float';
112
115
  environment: string;
113
116
  caption?: LatexInline[];
117
+ blocks: LatexBlock[];
114
118
  span: LatexSpan;
115
119
  } | {
116
120
  kind: 'unresolved';
@@ -629,15 +629,16 @@ function readEnvironment(ctx, name, start, contentStart, end, depth) {
629
629
  };
630
630
  }
631
631
  if (FLOAT_ENVIRONMENTS.has(name)) {
632
- // L7: floats are not typeset; the caption is the one piece of the float
633
- // that carries meaning without layout, so it is extracted and the rest
634
- // is reported rather than silently dropped.
632
+ // The caption is lifted out for display, but the body is scanned like
633
+ // any other content (L22). Extracting only the caption — the original
634
+ // L7 reading, from before tables rendered — discarded the `tabular` that
635
+ // is the whole point of a `table` float, and not even as source.
635
636
  const caption = readCaption(ctx, inner, depth);
636
- const graphic = findCommand(inner, 'includegraphics', 0, inner.length);
637
- if (graphic !== -1)
638
- addDiagnostic(ctx, 'warning', 'latex.unresolved-input', 'diag.latex.unresolved-input', { command: 'includegraphics', path: readGraphicPath(inner, graphic) });
639
- addDiagnostic(ctx, 'info', 'latex.float-caption-only', 'diag.latex.float-caption-only', { environment: name });
640
- return { blocks: made(ctx, { kind: 'float', environment: name, ...(caption ? { caption } : {}), span }), next: closing.after };
637
+ const blocks = scanBlocks(ctx, contentStart, closing.contentEnd, depth + 1);
638
+ return {
639
+ blocks: made(ctx, { kind: 'float', environment: name, ...(caption ? { caption } : {}), blocks, span }),
640
+ next: closing.after
641
+ };
641
642
  }
642
643
  addDiagnostic(ctx, 'warning', 'latex.unsupported-environment', 'diag.latex.unsupported-environment', { environment: name });
643
644
  return { blocks: made(ctx, { kind: 'unsupported', environment: name, source: text.slice(start, closing.after), span }), next: closing.after };
@@ -1213,6 +1214,19 @@ function readInlineCommand(ctx, source, control, depth, out, flush) {
1213
1214
  const group = readGroup(source, control.end, source.length);
1214
1215
  return group ? group.end : control.end;
1215
1216
  }
1217
+ // The float's caption is lifted out separately, so the copy in the body is
1218
+ // consumed here. `\caption*` and the optional short form both occur, which
1219
+ // is why this is not a plain entry in SILENT_COMMANDS.
1220
+ if (control.value === 'caption') {
1221
+ let cursor = control.end;
1222
+ if (source[cursor] === '*')
1223
+ cursor++;
1224
+ const optional = readOptional(source, cursor, source.length);
1225
+ if (optional)
1226
+ cursor = optional.end;
1227
+ const group = readGroup(source, skipSpace(source, cursor), source.length);
1228
+ return group ? group.end : cursor;
1229
+ }
1216
1230
  // `\let\abs=\envert` — the `=` is optional, so a fixed argument count would
1217
1231
  // leave part of it behind as prose.
1218
1232
  if (control.value === 'let') {
@@ -38,7 +38,7 @@
38
38
  .omni-latex__theorem{margin:0 0 1em;padding:12px 14px;border-left:3px solid var(--tex-border);background:rgba(127,127,127,.06);border-radius:0 6px 6px 0}.omni-latex__theorem-title{margin-bottom:.5em;font-weight:600}.omni-latex__theorem-note{margin-left:6px;font-weight:400;color:var(--tex-muted)}.omni-latex__theorem-body{font-style:italic}.omni-latex__theorem-body p:last-child{margin-bottom:0}
39
39
  .omni-latex__verbatim{margin:0 0 .9em;padding:12px 14px;border:1px solid var(--tex-border);border-radius:6px;background:rgba(127,127,127,.08);font:12px/1.5 var(--omni-mono,monospace);white-space:pre;overflow-x:auto}
40
40
  .omni-latex__unsupported{margin:0 0 .9em;border:1px dashed var(--tex-border);border-radius:6px;background:rgba(127,127,127,.06);overflow:hidden}.omni-latex__badge{display:block;padding:6px 12px;border-bottom:1px dashed var(--tex-border);color:var(--omni-warning,#ffd98a);font-size:11px;font-weight:600;letter-spacing:.02em}.omni-latex__unsupported pre{margin:0;padding:12px 14px;font:12px/1.5 var(--omni-mono,monospace);white-space:pre-wrap;overflow-x:auto;color:var(--tex-muted)}.omni-latex__unsupported--inline{display:inline;border:0;background:none;padding:0 2px;color:var(--tex-muted);font:12px/1.5 var(--omni-mono,monospace)}
41
- .omni-latex__float{margin:0 0 .9em;padding:12px 14px;border:1px dashed var(--tex-border);border-radius:6px;text-align:center;color:var(--tex-muted)}.omni-latex__float-caption{color:var(--omni-fg,#d4d4d4)}
41
+ .omni-latex__float{margin:0 0 1em;padding:12px 14px;border:1px solid var(--tex-border);border-radius:6px}.omni-latex__float-body{display:flex;flex-direction:column;align-items:center}.omni-latex__float-body>*{max-width:100%}.omni-latex__float-caption{margin-top:.6em;color:var(--tex-muted);font-size:.94em;text-align:center}
42
42
  .omni-latex__source{flex:1;width:100%;min-height:62vh;resize:none;border:0;outline:0;padding:16px;background:var(--omni-editor-bg,var(--omni-bg,#1e1e1e));color:inherit;font:13px/1.55 var(--omni-mono,monospace);tab-size:4}
43
43
  .omni-latex__warning{padding:10px 12px;border:1px solid #b98b2f;border-radius:6px;background:rgba(185,139,47,.14);color:var(--omni-warning,#ffd98a);white-space:pre-wrap}.omni-latex__warning[hidden]{display:none}.omni-latex__message{padding:10px 12px;border:1px solid var(--omni-error,#f44747);border-radius:6px;color:var(--omni-error,#f44747);white-space:pre-wrap}.omni-latex__message[hidden]{display:none}
44
44
  @media(max-width:900px){.omni-latex__workspace,.omni-latex__workspace.is-split{grid-template-columns:1fr}}
@@ -115,7 +115,12 @@ export interface CsvController {
115
115
  /** Whole document in original order as normalized CSV (save/writeback —
116
116
  * ignores search and sort so hidden rows are never dropped). */
117
117
  toDocumentCsv(): string;
118
- /** Mark the current edit depth as the saved point (clears dirty). */
118
+ /**
119
+ * Mark the current edit depth as the saved point (clears dirty). Writes are
120
+ * async and the grid stays editable during one, so the caller must confirm
121
+ * that `toDocumentCsv()` still equals what it wrote before calling this —
122
+ * otherwise an edit made mid-write is recorded as being on disk.
123
+ */
119
124
  markSaved(): void;
120
125
  /** Decoded source text (raw view). */
121
126
  readonly rawText: string;
@@ -216,13 +216,25 @@ export async function mountCsvViewer(input, container, ctx, options = {}) {
216
216
  saveAsBtn.title = t('common.noFileSave');
217
217
  }
218
218
  const dirtyBadge = el('span', 'omni-csv__dirty');
219
+ /**
220
+ * Clear dirty only if the document still equals the bytes that reached the
221
+ * file. The grid stays editable across the async write, so a cell changed
222
+ * mid-write would otherwise be recorded as saved — and a host trusting
223
+ * `isDirty()` would discard it on re-mount. When it differs, dirty is left
224
+ * set: the next save resolves it, and erring toward "unsaved" cannot lose
225
+ * work.
226
+ */
227
+ function markSavedIfUnchanged(written) {
228
+ if (controller.toDocumentCsv() === written)
229
+ controller.markSaved();
230
+ }
219
231
  async function saveDocument(service) {
220
232
  try {
221
233
  // Save covers the whole document in original order — never the
222
234
  // filtered/sorted display result (docs §6).
223
- const bytes = new TextEncoder().encode(controller.toDocumentCsv());
224
- await service.write(bytes);
225
- controller.markSaved();
235
+ const written = controller.toDocumentCsv();
236
+ await service.write(new TextEncoder().encode(written));
237
+ markSavedIfUnchanged(written);
226
238
  showToast(t('common.savedToOriginal'));
227
239
  }
228
240
  catch (error) {
@@ -237,9 +249,9 @@ export async function mountCsvViewer(input, container, ctx, options = {}) {
237
249
  const name = saveAsFileName(input.fileName, state.delimiter);
238
250
  const mime = state.delimiter === '\t' ? 'text/tab-separated-values' : 'text/csv';
239
251
  try {
240
- const bytes = new TextEncoder().encode(controller.toDocumentCsv());
241
- await service.saveFile(name, bytes, mime);
242
- controller.markSaved();
252
+ const written = controller.toDocumentCsv();
253
+ await service.saveFile(name, new TextEncoder().encode(written), mime);
254
+ markSavedIfUnchanged(written);
243
255
  showToast(t('common.saved', { name }));
244
256
  }
245
257
  catch (error) {
@@ -19,8 +19,14 @@ export type DiagramAction = {
19
19
  type: 'undo';
20
20
  } | {
21
21
  type: 'redo';
22
- } | {
22
+ }
23
+ /** `source` is the text that actually reached the file. Writes are async and
24
+ * the editor stays live during one, so the current source at completion is
25
+ * not necessarily what was written (mirrors the latex/markdown controllers).
26
+ * Omitted = "what is in the editor now". */
27
+ | {
23
28
  type: 'mark-saved';
29
+ source?: string;
24
30
  };
25
31
  export interface DiagramViewState {
26
32
  mode: DiagramViewMode;
@@ -38,7 +38,8 @@ export function createDiagramController(text, theme) {
38
38
  setSource(redo.pop());
39
39
  }
40
40
  else if (action.type === 'mark-saved') {
41
- state = { ...state, savedSource: state.source, dirty: false };
41
+ const savedSource = action.source ?? state.source;
42
+ state = { ...state, savedSource, dirty: state.source !== savedSource };
42
43
  }
43
44
  emit();
44
45
  },
@@ -8,6 +8,11 @@ export type DiagramViewerContext = HostContext & {
8
8
  writeback?: FileWritebackService;
9
9
  save?: FileSaveService;
10
10
  };
11
+ /** Diagram mount handle: `isDirty()` lets a host guard a re-mount against
12
+ * discarding unsaved source edits (DESIGN.md §3-②). */
13
+ export interface DiagramViewerHandle extends ViewerHandle {
14
+ isDirty(): boolean;
15
+ }
11
16
  export interface DiagramMountOptions extends MountOptions {
12
17
  renderMermaid?(id: string, source: string, theme?: DiagramTheme): Promise<string>;
13
18
  renderPlantUml?(source: string, document: Document, theme?: DiagramTheme): SVGElement | string;
@@ -20,5 +25,5 @@ export interface DiagramMountOptions extends MountOptions {
20
25
  export declare function mountDiagramViewer(kind: DiagramKind, parsed: {
21
26
  source: string;
22
27
  warnings: string[];
23
- }, input: ViewerInput, container: HTMLElement, ctx: DiagramViewerContext, options?: DiagramMountOptions): Promise<ViewerHandle>;
28
+ }, input: ViewerInput, container: HTMLElement, ctx: DiagramViewerContext, options?: DiagramMountOptions): Promise<DiagramViewerHandle>;
24
29
  export declare function sanitizeSvg(svg: string, maxBytes?: number, maxNodes?: number): SVGElement;
@@ -181,13 +181,20 @@ export async function mountDiagramViewer(kind, parsed, input, container, ctx, op
181
181
  }
182
182
  };
183
183
  const saveSource = async () => {
184
- const bytes = new TextEncoder().encode(controller.state.source);
184
+ // Snapshot once: the write is async and the editor stays live, so
185
+ // `controller.state.source` after the await may already be a later edit
186
+ // than the bytes that reached the file.
187
+ const saved = controller.state.source;
188
+ const bytes = new TextEncoder().encode(saved);
185
189
  if (ctx.writeback) {
190
+ // The caption is the dirty indicator: leave syncState's verdict alone
191
+ // when an edit arrived mid-write and the source is still unsaved.
186
192
  try {
187
193
  await ctx.writeback.write(bytes);
188
- controller.dispatch({ type: 'mark-saved' });
194
+ controller.dispatch({ type: 'mark-saved', source: saved });
189
195
  setStatus('common.savedToOriginal', 'valid');
190
- sourceCaption.textContent = t('common.savedToOriginal');
196
+ if (!controller.state.dirty)
197
+ sourceCaption.textContent = t('common.savedToOriginal');
191
198
  }
192
199
  catch (error) {
193
200
  ctx.logger.log('error', `diagram save failed: ${String(error)}`);
@@ -296,7 +303,11 @@ export async function mountDiagramViewer(kind, parsed, input, container, ctx, op
296
303
  container.classList.remove(VIEWER_ROOT_CLASS, `omni-viewer--${kind}`);
297
304
  else
298
305
  root.replaceChildren();
299
- }
306
+ },
307
+ // The controller, not the textarea: `mark-saved` (a successful writeback)
308
+ // is the only thing that clears it, so a failed save or a Save As copy
309
+ // stays dirty.
310
+ isDirty: () => controller.state.dirty
300
311
  };
301
312
  }
302
313
  let nextId = 1;
@@ -12,8 +12,17 @@ export type LatexAction = {
12
12
  type: 'undo';
13
13
  } | {
14
14
  type: 'redo';
15
- } | {
15
+ }
16
+ /**
17
+ * `source` is the text that actually reached the file. Writes are async and
18
+ * the editor stays live during one, so the current source at completion is
19
+ * not necessarily what was written — omitting it would mark an edit made
20
+ * mid-write as saved and let a host discard it. Omitted = "what is in the
21
+ * editor now", which is only correct for synchronous saves.
22
+ */
23
+ | {
16
24
  type: 'mark-saved';
25
+ source?: string;
17
26
  };
18
27
  export interface LatexViewState {
19
28
  mode: LatexViewMode;
@@ -52,7 +52,8 @@ export function createLatexController(text, mode = 'preview') {
52
52
  setSource(redo.pop());
53
53
  }
54
54
  else if (action.type === 'mark-saved') {
55
- state = { ...state, savedSource: state.source, dirty: false };
55
+ const savedSource = action.source ?? state.source;
56
+ state = { ...state, savedSource, dirty: state.source !== savedSource };
56
57
  }
57
58
  emit();
58
59
  },
@@ -28,6 +28,12 @@ export interface LatexViewerDeps {
28
28
  math?: MathRenderer;
29
29
  createDOMPurify?(window: Window): DomPurify;
30
30
  }
31
+ /** LaTeX mount handle: dirty inspection lets hosts guard a re-mount (file
32
+ * change, refresh) against discarding unsaved edits, without reaching into the
33
+ * viewer's DOM (csv/image/pdf expose the same method). */
34
+ export interface LatexViewerHandle extends ViewerHandle {
35
+ isDirty(): boolean;
36
+ }
31
37
  export interface LatexMountOptions extends MountOptions {
32
38
  /** Forwarded to the parser (limits, cancellation). */
33
39
  parse?: LatexParseOptions;
@@ -57,7 +63,7 @@ export declare const LATEX_VIEWER_META: {
57
63
  optionalServices: readonly ["clipboard", "navigation", "writeback", "save"];
58
64
  inputOwnership: "borrows";
59
65
  };
60
- export declare function mountLatexViewer(input: ViewerInput, container: HTMLElement, ctx: LatexViewerContext, options?: LatexMountOptions): Promise<ViewerHandle>;
66
+ export declare function mountLatexViewer(input: ViewerInput, container: HTMLElement, ctx: LatexViewerContext, options?: LatexMountOptions): Promise<LatexViewerHandle>;
61
67
  /**
62
68
  * Commands that produce no glyphs but that math engines do not implement.
63
69
  * Collecting the document's own macros (L18) means a text-mode macro can now
@@ -387,10 +387,12 @@ export async function mountLatexViewer(input, container, ctx, options = {}) {
387
387
  continue;
388
388
  }
389
389
  if (block.kind === 'float') {
390
- const float = el('div', 'omni-latex__float');
391
- float.append(el('div', 'omni-latex__badge', t('latex.floatCaptionOnly', { environment: block.environment })));
390
+ const float = el('figure', 'omni-latex__float');
391
+ const body = el('div', 'omni-latex__float-body');
392
+ renderBlocks(block.blocks, body);
393
+ float.append(body);
392
394
  if (block.caption) {
393
- const caption = el('div', 'omni-latex__float-caption');
395
+ const caption = el('figcaption', 'omni-latex__float-caption');
394
396
  renderInline(block.caption, caption);
395
397
  float.append(caption);
396
398
  }
@@ -541,11 +543,15 @@ export async function mountLatexViewer(input, container, ctx, options = {}) {
541
543
  }
542
544
  };
543
545
  const saveSource = async () => {
544
- const bytes = new TextEncoder().encode(controller.state.source);
546
+ // Snapshot once: the write is async and the editor stays live, so
547
+ // `controller.state.source` after the await may already be a later edit
548
+ // than the bytes that reached the file.
549
+ const saved = controller.state.source;
550
+ const bytes = new TextEncoder().encode(saved);
545
551
  if (ctx.writeback) {
546
552
  try {
547
553
  await ctx.writeback.write(bytes);
548
- controller.dispatch({ type: 'mark-saved' });
554
+ controller.dispatch({ type: 'mark-saved', source: saved });
549
555
  setStatus('common.savedToOriginal', 'valid');
550
556
  }
551
557
  catch (error) {
@@ -639,7 +645,10 @@ export async function mountLatexViewer(input, container, ctx, options = {}) {
639
645
  teardown();
640
646
  throw new MountAbortedError();
641
647
  }
642
- return { dispose: teardown };
648
+ // Reads the controller rather than the textarea: the source of truth for
649
+ // "unsaved" is `source !== savedSource`, which undo restores and `mark-saved`
650
+ // clears. A Save As copy never dispatches `mark-saved`, so it stays dirty.
651
+ return { dispose: teardown, isDirty: () => controller.state.dirty };
643
652
  }
644
653
  function errorText(error, fallback) {
645
654
  return error instanceof Error ? error.message : fallback;
@@ -682,7 +691,7 @@ function hasMath(blocks) {
682
691
  || (block.kind === 'theorem' && hasMath(block.body))
683
692
  || (block.kind === 'include' && hasMath(block.blocks))
684
693
  || (block.kind === 'table' && block.rows.some(row => row.some(cell => cell.content.some(node => node.kind === 'math'))))
685
- || (block.kind === 'float' && (block.caption ?? []).some(node => node.kind === 'math')));
694
+ || (block.kind === 'float' && (hasMath(block.blocks) || (block.caption ?? []).some(node => node.kind === 'math'))));
686
695
  }
687
696
  /** Heading ids are core-generated (`heading-3`), so escaping only needs to
688
697
  * cover the leading-digit case CSS selectors reject. */
@@ -1 +1 @@
1
- export declare const latexViewerCss = "\n.omni-viewer--latex{box-sizing:border-box;min-height:100%;padding:18px;display:flex;flex-direction:column;gap:14px;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:var(--omni-font-size,13px)/1.5 var(--omni-font-family,system-ui,sans-serif);--tex-border:var(--omni-border,#454545);--tex-muted:var(--omni-muted,#aaa);--tex-panel:var(--omni-panel-bg,rgba(127,127,127,.05))}\n.omni-latex *{box-sizing:border-box}.omni-latex__header,.omni-latex__toolbar,.omni-latex__toolbar-group,.omni-latex__panel-header{display:flex;align-items:center;gap:10px}.omni-latex__header{align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}.omni-latex__title{font-size:16px;font-weight:600;margin-bottom:4px;word-break:break-word}.omni-latex__meta,.omni-latex__caption{color:var(--tex-muted);font-size:12px}\n.omni-latex__status{padding:7px 10px;border-radius:999px;border:1px solid var(--tex-border);background:rgba(79,193,255,.16);font-size:12px;font-weight:600;white-space:nowrap}.omni-latex__status.is-valid{background:rgba(46,160,67,.16);color:var(--omni-success,#3fb950)}.omni-latex__status.is-invalid{background:rgba(244,71,71,.14);color:var(--omni-error,#f44747)}\n.omni-latex__partial{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid #b98b2f;border-radius:999px;background:rgba(185,139,47,.14);color:var(--omni-warning,#ffd98a);font-size:12px;font-weight:600}\n.omni-latex__toolbar{flex-wrap:wrap}.omni-latex__toolbar-group{flex-wrap:wrap;padding:7px;border:1px solid var(--tex-border);border-radius:8px;background:var(--tex-panel)}.omni-latex__button{cursor:pointer;border:1px solid var(--tex-border);background:var(--omni-button-secondary-bg,rgba(127,127,127,.16));color:inherit;border-radius:6px;padding:7px 10px;font:inherit}.omni-latex__button:hover:not(:disabled){background:rgba(127,127,127,.26)}.omni-latex__button.is-active{border-color:var(--omni-focus,#007fd4);background:color-mix(in srgb,var(--omni-focus,#007fd4) 22%,transparent)}.omni-latex__button.is-dirty{border-color:var(--omni-warning,#cca700)}.omni-latex__button:disabled{cursor:default;opacity:.5}\n/* Reading views are outline + one pane; split drops the outline and gives the\n preview and the editor an equal half each. */\n.omni-latex__workspace{display:grid;grid-template-columns:minmax(200px,240px) minmax(320px,1fr);gap:14px;min-height:68vh}.omni-latex__workspace.is-split{grid-template-columns:minmax(280px,1fr) minmax(280px,1fr)}.omni-latex__panel{display:flex;flex-direction:column;min-height:0;border:1px solid var(--tex-border);border-radius:8px;background:var(--tex-panel);overflow:hidden}.omni-latex__panel[hidden]{display:none}.omni-latex__panel-header{justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--tex-border)}\n.omni-latex__outline{flex:1;min-height:0;overflow:auto;padding:8px;margin:0;list-style:none}.omni-latex__outline-item{margin:0}.omni-latex__outline-link{display:block;width:100%;text-align:left;cursor:pointer;border:0;background:transparent;color:inherit;font:inherit;padding:5px 8px;border-radius:5px;word-break:break-word}.omni-latex__outline-link:hover{background:rgba(127,127,127,.18)}.omni-latex__outline-link.is-active{background:color-mix(in srgb,var(--omni-focus,#007fd4) 24%,transparent)}.omni-latex__outline-link[data-level=\"2\"]{padding-left:20px}.omni-latex__outline-link[data-level=\"3\"]{padding-left:32px}.omni-latex__outline-link[data-level=\"4\"]{padding-left:44px}.omni-latex__outline-link[data-level=\"5\"]{padding-left:56px}.omni-latex__outline-link[data-level=\"6\"]{padding-left:68px}.omni-latex__outline-empty{padding:10px;color:var(--tex-muted)}\n.omni-latex__preview{flex:1;min-height:0;overflow:auto;padding:22px 26px;background:var(--omni-editor-bg,var(--omni-bg,#1e1e1e))}.omni-latex__preview h1,.omni-latex__preview h2,.omni-latex__preview h3,.omni-latex__preview h4,.omni-latex__preview h5,.omni-latex__preview h6{margin:1.4em 0 .5em;line-height:1.3;scroll-margin-top:12px}.omni-latex__preview h1{font-size:1.7em}.omni-latex__preview h2{font-size:1.45em}.omni-latex__preview h3{font-size:1.25em}.omni-latex__preview p{margin:0 0 .9em}.omni-latex__preview ul,.omni-latex__preview ol{margin:0 0 .9em;padding-left:1.6em}.omni-latex__preview li{margin:.25em 0}\n.omni-latex__math{display:block;margin:1em 0;padding:10px 12px;border-left:3px solid var(--omni-focus,#007fd4);background:rgba(127,127,127,.08);font:13px/1.5 var(--omni-mono,monospace);white-space:pre-wrap;overflow-x:auto}.omni-latex__math--inline{display:inline;margin:0;padding:1px 5px;border-left:0;border-radius:4px}\n/* Once rendered the slot is KaTeX's own layout, so the source-code framing is\n dropped. Two things matter here beyond looks:\n - overflow is 'auto hidden', never 'auto'. A formula that is a pixel too wide\n raises a horizontal scrollbar, which eats content-box height, which raises a\n vertical scrollbar, which narrows the box, which keeps the horizontal one \u2014\n a loop that puts both bars on a formula with room to spare.\n - line-height returns to normal. KaTeX sizes its own struts, and inheriting\n the body's 1.5 makes the rendered box taller than the line it sits on. */\n.omni-latex__math.is-rendered{padding:.4em .2em;border-left:0;background:none;font:inherit;line-height:normal;white-space:normal;text-align:center;overflow:auto hidden}\n.omni-latex__math--inline.is-rendered{padding:0;text-align:inherit;overflow:visible}.omni-latex__math.is-pending{opacity:.75}\n/* A formula the engine refused is TeX source, not broken output \u2014 it keeps the\n source framing (mono, left aligned, muted) with a red rule to mark the\n failure. Colouring the whole block red reads as \"this document is wrong\". */\n.omni-latex__math.is-invalid{border-left:3px solid var(--omni-error,#f44747);color:var(--tex-muted);text-align:left;font:12px/1.5 var(--omni-mono,monospace);white-space:pre-wrap;padding:10px 12px;background:rgba(244,71,71,.06)}\n.omni-latex__math--inline.is-invalid{border-left:0;padding:1px 5px;text-decoration:underline wavy var(--omni-error,#f44747)}\n/* Belt and braces for renderers that do not honour an errorColor option: KaTeX\n writes its error colour as an inline style, so overriding needs !important. */\n.omni-latex__math .katex-error{color:var(--omni-warning,#cca700)!important;text-decoration:underline dotted}\n.omni-latex__math .katex-display{margin:0;padding:0}.omni-latex__math .katex-display>.katex{max-width:100%}.omni-latex__math .katex{font-size:1.05em}\n.omni-latex__cite{padding:0 3px;border-radius:4px;background:rgba(127,127,127,.18);color:var(--tex-muted);font-size:.92em}\n.omni-latex__ref{padding:0 3px;border:0;border-radius:4px;background:rgba(127,127,127,.18);color:var(--tex-muted);font:inherit;font-size:.92em;cursor:pointer}.omni-latex__ref:hover{background:rgba(127,127,127,.3);color:var(--omni-fg,#d4d4d4)}\n.omni-latex__preview .is-target{outline:2px solid var(--omni-focus,#007fd4);outline-offset:3px;border-radius:4px;transition:outline-color .3s}\n.omni-latex__link{color:var(--omni-link,#4fc1ff);cursor:pointer;background:none;border:0;padding:0;font:inherit;text-decoration:underline}.omni-latex__link:disabled{cursor:default;text-decoration:none;color:inherit}\n.omni-latex__include{margin:0 0 1em;padding-left:12px;border-left:2px dashed var(--tex-border)}.omni-latex__include-label{margin-bottom:.6em;color:var(--tex-muted);font-size:11px;font-weight:600;letter-spacing:.02em}\n.omni-latex__table-wrap{margin:0 0 1em;overflow-x:auto}.omni-latex__table{border-collapse:collapse;min-width:min(100%,340px);font-size:.96em}.omni-latex__table th,.omni-latex__table td{border:1px solid var(--tex-border);padding:6px 10px;vertical-align:top}.omni-latex__table th{background:rgba(127,127,127,.12);font-weight:600;text-align:inherit}\n.omni-latex__theorem{margin:0 0 1em;padding:12px 14px;border-left:3px solid var(--tex-border);background:rgba(127,127,127,.06);border-radius:0 6px 6px 0}.omni-latex__theorem-title{margin-bottom:.5em;font-weight:600}.omni-latex__theorem-note{margin-left:6px;font-weight:400;color:var(--tex-muted)}.omni-latex__theorem-body{font-style:italic}.omni-latex__theorem-body p:last-child{margin-bottom:0}\n.omni-latex__verbatim{margin:0 0 .9em;padding:12px 14px;border:1px solid var(--tex-border);border-radius:6px;background:rgba(127,127,127,.08);font:12px/1.5 var(--omni-mono,monospace);white-space:pre;overflow-x:auto}\n.omni-latex__unsupported{margin:0 0 .9em;border:1px dashed var(--tex-border);border-radius:6px;background:rgba(127,127,127,.06);overflow:hidden}.omni-latex__badge{display:block;padding:6px 12px;border-bottom:1px dashed var(--tex-border);color:var(--omni-warning,#ffd98a);font-size:11px;font-weight:600;letter-spacing:.02em}.omni-latex__unsupported pre{margin:0;padding:12px 14px;font:12px/1.5 var(--omni-mono,monospace);white-space:pre-wrap;overflow-x:auto;color:var(--tex-muted)}.omni-latex__unsupported--inline{display:inline;border:0;background:none;padding:0 2px;color:var(--tex-muted);font:12px/1.5 var(--omni-mono,monospace)}\n.omni-latex__float{margin:0 0 .9em;padding:12px 14px;border:1px dashed var(--tex-border);border-radius:6px;text-align:center;color:var(--tex-muted)}.omni-latex__float-caption{color:var(--omni-fg,#d4d4d4)}\n.omni-latex__source{flex:1;width:100%;min-height:62vh;resize:none;border:0;outline:0;padding:16px;background:var(--omni-editor-bg,var(--omni-bg,#1e1e1e));color:inherit;font:13px/1.55 var(--omni-mono,monospace);tab-size:4}\n.omni-latex__warning{padding:10px 12px;border:1px solid #b98b2f;border-radius:6px;background:rgba(185,139,47,.14);color:var(--omni-warning,#ffd98a);white-space:pre-wrap}.omni-latex__warning[hidden]{display:none}.omni-latex__message{padding:10px 12px;border:1px solid var(--omni-error,#f44747);border-radius:6px;color:var(--omni-error,#f44747);white-space:pre-wrap}.omni-latex__message[hidden]{display:none}\n@media(max-width:900px){.omni-latex__workspace,.omni-latex__workspace.is-split{grid-template-columns:1fr}}\n";
1
+ export declare const latexViewerCss = "\n.omni-viewer--latex{box-sizing:border-box;min-height:100%;padding:18px;display:flex;flex-direction:column;gap:14px;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:var(--omni-font-size,13px)/1.5 var(--omni-font-family,system-ui,sans-serif);--tex-border:var(--omni-border,#454545);--tex-muted:var(--omni-muted,#aaa);--tex-panel:var(--omni-panel-bg,rgba(127,127,127,.05))}\n.omni-latex *{box-sizing:border-box}.omni-latex__header,.omni-latex__toolbar,.omni-latex__toolbar-group,.omni-latex__panel-header{display:flex;align-items:center;gap:10px}.omni-latex__header{align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}.omni-latex__title{font-size:16px;font-weight:600;margin-bottom:4px;word-break:break-word}.omni-latex__meta,.omni-latex__caption{color:var(--tex-muted);font-size:12px}\n.omni-latex__status{padding:7px 10px;border-radius:999px;border:1px solid var(--tex-border);background:rgba(79,193,255,.16);font-size:12px;font-weight:600;white-space:nowrap}.omni-latex__status.is-valid{background:rgba(46,160,67,.16);color:var(--omni-success,#3fb950)}.omni-latex__status.is-invalid{background:rgba(244,71,71,.14);color:var(--omni-error,#f44747)}\n.omni-latex__partial{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid #b98b2f;border-radius:999px;background:rgba(185,139,47,.14);color:var(--omni-warning,#ffd98a);font-size:12px;font-weight:600}\n.omni-latex__toolbar{flex-wrap:wrap}.omni-latex__toolbar-group{flex-wrap:wrap;padding:7px;border:1px solid var(--tex-border);border-radius:8px;background:var(--tex-panel)}.omni-latex__button{cursor:pointer;border:1px solid var(--tex-border);background:var(--omni-button-secondary-bg,rgba(127,127,127,.16));color:inherit;border-radius:6px;padding:7px 10px;font:inherit}.omni-latex__button:hover:not(:disabled){background:rgba(127,127,127,.26)}.omni-latex__button.is-active{border-color:var(--omni-focus,#007fd4);background:color-mix(in srgb,var(--omni-focus,#007fd4) 22%,transparent)}.omni-latex__button.is-dirty{border-color:var(--omni-warning,#cca700)}.omni-latex__button:disabled{cursor:default;opacity:.5}\n/* Reading views are outline + one pane; split drops the outline and gives the\n preview and the editor an equal half each. */\n.omni-latex__workspace{display:grid;grid-template-columns:minmax(200px,240px) minmax(320px,1fr);gap:14px;min-height:68vh}.omni-latex__workspace.is-split{grid-template-columns:minmax(280px,1fr) minmax(280px,1fr)}.omni-latex__panel{display:flex;flex-direction:column;min-height:0;border:1px solid var(--tex-border);border-radius:8px;background:var(--tex-panel);overflow:hidden}.omni-latex__panel[hidden]{display:none}.omni-latex__panel-header{justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--tex-border)}\n.omni-latex__outline{flex:1;min-height:0;overflow:auto;padding:8px;margin:0;list-style:none}.omni-latex__outline-item{margin:0}.omni-latex__outline-link{display:block;width:100%;text-align:left;cursor:pointer;border:0;background:transparent;color:inherit;font:inherit;padding:5px 8px;border-radius:5px;word-break:break-word}.omni-latex__outline-link:hover{background:rgba(127,127,127,.18)}.omni-latex__outline-link.is-active{background:color-mix(in srgb,var(--omni-focus,#007fd4) 24%,transparent)}.omni-latex__outline-link[data-level=\"2\"]{padding-left:20px}.omni-latex__outline-link[data-level=\"3\"]{padding-left:32px}.omni-latex__outline-link[data-level=\"4\"]{padding-left:44px}.omni-latex__outline-link[data-level=\"5\"]{padding-left:56px}.omni-latex__outline-link[data-level=\"6\"]{padding-left:68px}.omni-latex__outline-empty{padding:10px;color:var(--tex-muted)}\n.omni-latex__preview{flex:1;min-height:0;overflow:auto;padding:22px 26px;background:var(--omni-editor-bg,var(--omni-bg,#1e1e1e))}.omni-latex__preview h1,.omni-latex__preview h2,.omni-latex__preview h3,.omni-latex__preview h4,.omni-latex__preview h5,.omni-latex__preview h6{margin:1.4em 0 .5em;line-height:1.3;scroll-margin-top:12px}.omni-latex__preview h1{font-size:1.7em}.omni-latex__preview h2{font-size:1.45em}.omni-latex__preview h3{font-size:1.25em}.omni-latex__preview p{margin:0 0 .9em}.omni-latex__preview ul,.omni-latex__preview ol{margin:0 0 .9em;padding-left:1.6em}.omni-latex__preview li{margin:.25em 0}\n.omni-latex__math{display:block;margin:1em 0;padding:10px 12px;border-left:3px solid var(--omni-focus,#007fd4);background:rgba(127,127,127,.08);font:13px/1.5 var(--omni-mono,monospace);white-space:pre-wrap;overflow-x:auto}.omni-latex__math--inline{display:inline;margin:0;padding:1px 5px;border-left:0;border-radius:4px}\n/* Once rendered the slot is KaTeX's own layout, so the source-code framing is\n dropped. Two things matter here beyond looks:\n - overflow is 'auto hidden', never 'auto'. A formula that is a pixel too wide\n raises a horizontal scrollbar, which eats content-box height, which raises a\n vertical scrollbar, which narrows the box, which keeps the horizontal one \u2014\n a loop that puts both bars on a formula with room to spare.\n - line-height returns to normal. KaTeX sizes its own struts, and inheriting\n the body's 1.5 makes the rendered box taller than the line it sits on. */\n.omni-latex__math.is-rendered{padding:.4em .2em;border-left:0;background:none;font:inherit;line-height:normal;white-space:normal;text-align:center;overflow:auto hidden}\n.omni-latex__math--inline.is-rendered{padding:0;text-align:inherit;overflow:visible}.omni-latex__math.is-pending{opacity:.75}\n/* A formula the engine refused is TeX source, not broken output \u2014 it keeps the\n source framing (mono, left aligned, muted) with a red rule to mark the\n failure. Colouring the whole block red reads as \"this document is wrong\". */\n.omni-latex__math.is-invalid{border-left:3px solid var(--omni-error,#f44747);color:var(--tex-muted);text-align:left;font:12px/1.5 var(--omni-mono,monospace);white-space:pre-wrap;padding:10px 12px;background:rgba(244,71,71,.06)}\n.omni-latex__math--inline.is-invalid{border-left:0;padding:1px 5px;text-decoration:underline wavy var(--omni-error,#f44747)}\n/* Belt and braces for renderers that do not honour an errorColor option: KaTeX\n writes its error colour as an inline style, so overriding needs !important. */\n.omni-latex__math .katex-error{color:var(--omni-warning,#cca700)!important;text-decoration:underline dotted}\n.omni-latex__math .katex-display{margin:0;padding:0}.omni-latex__math .katex-display>.katex{max-width:100%}.omni-latex__math .katex{font-size:1.05em}\n.omni-latex__cite{padding:0 3px;border-radius:4px;background:rgba(127,127,127,.18);color:var(--tex-muted);font-size:.92em}\n.omni-latex__ref{padding:0 3px;border:0;border-radius:4px;background:rgba(127,127,127,.18);color:var(--tex-muted);font:inherit;font-size:.92em;cursor:pointer}.omni-latex__ref:hover{background:rgba(127,127,127,.3);color:var(--omni-fg,#d4d4d4)}\n.omni-latex__preview .is-target{outline:2px solid var(--omni-focus,#007fd4);outline-offset:3px;border-radius:4px;transition:outline-color .3s}\n.omni-latex__link{color:var(--omni-link,#4fc1ff);cursor:pointer;background:none;border:0;padding:0;font:inherit;text-decoration:underline}.omni-latex__link:disabled{cursor:default;text-decoration:none;color:inherit}\n.omni-latex__include{margin:0 0 1em;padding-left:12px;border-left:2px dashed var(--tex-border)}.omni-latex__include-label{margin-bottom:.6em;color:var(--tex-muted);font-size:11px;font-weight:600;letter-spacing:.02em}\n.omni-latex__table-wrap{margin:0 0 1em;overflow-x:auto}.omni-latex__table{border-collapse:collapse;min-width:min(100%,340px);font-size:.96em}.omni-latex__table th,.omni-latex__table td{border:1px solid var(--tex-border);padding:6px 10px;vertical-align:top}.omni-latex__table th{background:rgba(127,127,127,.12);font-weight:600;text-align:inherit}\n.omni-latex__theorem{margin:0 0 1em;padding:12px 14px;border-left:3px solid var(--tex-border);background:rgba(127,127,127,.06);border-radius:0 6px 6px 0}.omni-latex__theorem-title{margin-bottom:.5em;font-weight:600}.omni-latex__theorem-note{margin-left:6px;font-weight:400;color:var(--tex-muted)}.omni-latex__theorem-body{font-style:italic}.omni-latex__theorem-body p:last-child{margin-bottom:0}\n.omni-latex__verbatim{margin:0 0 .9em;padding:12px 14px;border:1px solid var(--tex-border);border-radius:6px;background:rgba(127,127,127,.08);font:12px/1.5 var(--omni-mono,monospace);white-space:pre;overflow-x:auto}\n.omni-latex__unsupported{margin:0 0 .9em;border:1px dashed var(--tex-border);border-radius:6px;background:rgba(127,127,127,.06);overflow:hidden}.omni-latex__badge{display:block;padding:6px 12px;border-bottom:1px dashed var(--tex-border);color:var(--omni-warning,#ffd98a);font-size:11px;font-weight:600;letter-spacing:.02em}.omni-latex__unsupported pre{margin:0;padding:12px 14px;font:12px/1.5 var(--omni-mono,monospace);white-space:pre-wrap;overflow-x:auto;color:var(--tex-muted)}.omni-latex__unsupported--inline{display:inline;border:0;background:none;padding:0 2px;color:var(--tex-muted);font:12px/1.5 var(--omni-mono,monospace)}\n.omni-latex__float{margin:0 0 1em;padding:12px 14px;border:1px solid var(--tex-border);border-radius:6px}.omni-latex__float-body{display:flex;flex-direction:column;align-items:center}.omni-latex__float-body>*{max-width:100%}.omni-latex__float-caption{margin-top:.6em;color:var(--tex-muted);font-size:.94em;text-align:center}\n.omni-latex__source{flex:1;width:100%;min-height:62vh;resize:none;border:0;outline:0;padding:16px;background:var(--omni-editor-bg,var(--omni-bg,#1e1e1e));color:inherit;font:13px/1.55 var(--omni-mono,monospace);tab-size:4}\n.omni-latex__warning{padding:10px 12px;border:1px solid #b98b2f;border-radius:6px;background:rgba(185,139,47,.14);color:var(--omni-warning,#ffd98a);white-space:pre-wrap}.omni-latex__warning[hidden]{display:none}.omni-latex__message{padding:10px 12px;border:1px solid var(--omni-error,#f44747);border-radius:6px;color:var(--omni-error,#f44747);white-space:pre-wrap}.omni-latex__message[hidden]{display:none}\n@media(max-width:900px){.omni-latex__workspace,.omni-latex__workspace.is-split{grid-template-columns:1fr}}\n";
@@ -38,7 +38,7 @@ export const latexViewerCss = `
38
38
  .omni-latex__theorem{margin:0 0 1em;padding:12px 14px;border-left:3px solid var(--tex-border);background:rgba(127,127,127,.06);border-radius:0 6px 6px 0}.omni-latex__theorem-title{margin-bottom:.5em;font-weight:600}.omni-latex__theorem-note{margin-left:6px;font-weight:400;color:var(--tex-muted)}.omni-latex__theorem-body{font-style:italic}.omni-latex__theorem-body p:last-child{margin-bottom:0}
39
39
  .omni-latex__verbatim{margin:0 0 .9em;padding:12px 14px;border:1px solid var(--tex-border);border-radius:6px;background:rgba(127,127,127,.08);font:12px/1.5 var(--omni-mono,monospace);white-space:pre;overflow-x:auto}
40
40
  .omni-latex__unsupported{margin:0 0 .9em;border:1px dashed var(--tex-border);border-radius:6px;background:rgba(127,127,127,.06);overflow:hidden}.omni-latex__badge{display:block;padding:6px 12px;border-bottom:1px dashed var(--tex-border);color:var(--omni-warning,#ffd98a);font-size:11px;font-weight:600;letter-spacing:.02em}.omni-latex__unsupported pre{margin:0;padding:12px 14px;font:12px/1.5 var(--omni-mono,monospace);white-space:pre-wrap;overflow-x:auto;color:var(--tex-muted)}.omni-latex__unsupported--inline{display:inline;border:0;background:none;padding:0 2px;color:var(--tex-muted);font:12px/1.5 var(--omni-mono,monospace)}
41
- .omni-latex__float{margin:0 0 .9em;padding:12px 14px;border:1px dashed var(--tex-border);border-radius:6px;text-align:center;color:var(--tex-muted)}.omni-latex__float-caption{color:var(--omni-fg,#d4d4d4)}
41
+ .omni-latex__float{margin:0 0 1em;padding:12px 14px;border:1px solid var(--tex-border);border-radius:6px}.omni-latex__float-body{display:flex;flex-direction:column;align-items:center}.omni-latex__float-body>*{max-width:100%}.omni-latex__float-caption{margin-top:.6em;color:var(--tex-muted);font-size:.94em;text-align:center}
42
42
  .omni-latex__source{flex:1;width:100%;min-height:62vh;resize:none;border:0;outline:0;padding:16px;background:var(--omni-editor-bg,var(--omni-bg,#1e1e1e));color:inherit;font:13px/1.55 var(--omni-mono,monospace);tab-size:4}
43
43
  .omni-latex__warning{padding:10px 12px;border:1px solid #b98b2f;border-radius:6px;background:rgba(185,139,47,.14);color:var(--omni-warning,#ffd98a);white-space:pre-wrap}.omni-latex__warning[hidden]{display:none}.omni-latex__message{padding:10px 12px;border:1px solid var(--omni-error,#f44747);border-radius:6px;color:var(--omni-error,#f44747);white-space:pre-wrap}.omni-latex__message[hidden]{display:none}
44
44
  @media(max-width:900px){.omni-latex__workspace,.omni-latex__workspace.is-split{grid-template-columns:1fr}}
@@ -18,8 +18,13 @@ export type MarkdownAction = {
18
18
  type: 'undo';
19
19
  } | {
20
20
  type: 'redo';
21
- } | {
21
+ }
22
+ /** `source` is the text that actually reached the file. Writes are async and
23
+ * the editor stays live during one, so the current source at completion is
24
+ * not necessarily what was written. Omitted = "what is in the editor now". */
25
+ | {
22
26
  type: 'mark-saved';
27
+ source?: string;
23
28
  } | {
24
29
  type: 'copy-code';
25
30
  code: string;
@@ -52,7 +52,8 @@ export function createMarkdownController(text, _headings) {
52
52
  setSource(redo.pop());
53
53
  }
54
54
  else if (action.type === 'mark-saved') {
55
- state = { ...state, savedSource: state.source, dirty: false };
55
+ const savedSource = action.source ?? state.source;
56
+ state = { ...state, savedSource, dirty: state.source !== savedSource };
56
57
  }
57
58
  emit();
58
59
  },
@@ -56,6 +56,11 @@ export type MarkdownViewerContext = HostContext & {
56
56
  writeback?: FileWritebackService;
57
57
  save?: FileSaveService;
58
58
  };
59
+ /** Markdown mount handle: `isDirty()` lets a host guard a re-mount against
60
+ * discarding unsaved source edits (DESIGN.md §3-②). */
61
+ export interface MarkdownViewerHandle extends ViewerHandle {
62
+ isDirty(): boolean;
63
+ }
59
64
  export interface MarkdownMountOptions extends MountOptions {
60
65
  limits?: ResourceLimits;
61
66
  markdownLimits?: MarkdownParseOptions['markdownLimits'];
@@ -64,4 +69,4 @@ export interface MarkdownMountOptions extends MountOptions {
64
69
  * wants it configurable should drive this from its own settings. */
65
70
  scrollSync?: boolean;
66
71
  }
67
- export declare function mountMarkdownViewer(input: ViewerInput, container: HTMLElement, ctx: MarkdownViewerContext, deps: MarkdownViewerDeps, options?: MarkdownMountOptions): Promise<ViewerHandle>;
72
+ export declare function mountMarkdownViewer(input: ViewerInput, container: HTMLElement, ctx: MarkdownViewerContext, deps: MarkdownViewerDeps, options?: MarkdownMountOptions): Promise<MarkdownViewerHandle>;
@@ -546,13 +546,20 @@ export async function mountMarkdownViewer(input, container, ctx, deps, options =
546
546
  }
547
547
  };
548
548
  const saveSource = async () => {
549
- const bytes = new TextEncoder().encode(controller.state.source);
549
+ // Snapshot once: the write is async and the editor stays live, so
550
+ // `controller.state.source` after the await may already be a later edit
551
+ // than the bytes that reached the file.
552
+ const saved = controller.state.source;
553
+ const bytes = new TextEncoder().encode(saved);
550
554
  if (ctx.writeback) {
555
+ // The caption is the dirty indicator: leave syncState's verdict alone
556
+ // when an edit arrived mid-write and the source is still unsaved.
551
557
  try {
552
558
  await ctx.writeback.write(bytes);
553
- controller.dispatch({ type: 'mark-saved' });
559
+ controller.dispatch({ type: 'mark-saved', source: saved });
554
560
  setStatus('common.savedToOriginal', 'valid');
555
- sourceCaption.textContent = t('common.savedToOriginal');
561
+ if (!controller.state.dirty)
562
+ sourceCaption.textContent = t('common.savedToOriginal');
556
563
  }
557
564
  catch (error) {
558
565
  ctx.logger.log('error', `markdown save failed: ${String(error)}`);
@@ -739,9 +746,15 @@ export async function mountMarkdownViewer(input, container, ctx, deps, options =
739
746
  releaseAssets();
740
747
  throw new MountAbortedError();
741
748
  }
742
- return { dispose() { disposed = true; renderVersion++; if (liveTimer)
749
+ return {
750
+ dispose() { disposed = true; renderVersion++; if (liveTimer)
743
751
  clearTimeout(liveTimer); releaseAssets(); for (const dispose of disposers.splice(0))
744
- dispose(); shell.remove(); } };
752
+ dispose(); shell.remove(); },
753
+ // The controller, not the textarea: `mark-saved` (a successful writeback)
754
+ // is the only thing that clears it, so a failed save or a download copy
755
+ // stays dirty.
756
+ isDirty: () => controller.state.dirty
757
+ };
745
758
  }
746
759
  /** Flat text for an outline entry. Rendered math contributes twice to
747
760
  * `textContent` — KaTeX emits a visual copy alongside a MathML twin — so the
@@ -1,9 +1,10 @@
1
- import { diagramViewerCss, type DiagramMountOptions, type DiagramViewerContext } from '../diagram.js';
2
- import type { ViewerHandle, ViewerInput } from '../types.js';
1
+ import { diagramViewerCss, type DiagramMountOptions, type DiagramViewerContext, type DiagramViewerHandle } from '../diagram.js';
2
+ import type { ViewerInput } from '../types.js';
3
3
  export { parseMermaid } from '../../parsers/mermaid/index.js';
4
4
  export { diagramViewerCss as mermaidViewerCss };
5
5
  export type MermaidViewerContext = DiagramViewerContext;
6
6
  export type MermaidMountOptions = DiagramMountOptions;
7
+ export type MermaidViewerHandle = DiagramViewerHandle;
7
8
  export declare const MERMAID_VIEWER_META: {
8
9
  id: string;
9
10
  displayNameKey: string;
@@ -13,4 +14,4 @@ export declare const MERMAID_VIEWER_META: {
13
14
  optionalServices: readonly ["clipboard", "writeback", "save"];
14
15
  inputOwnership: "borrows";
15
16
  };
16
- export declare function mountMermaidViewer(input: ViewerInput, container: HTMLElement, ctx: MermaidViewerContext, options?: MermaidMountOptions): Promise<ViewerHandle>;
17
+ export declare function mountMermaidViewer(input: ViewerInput, container: HTMLElement, ctx: MermaidViewerContext, options?: MermaidMountOptions): Promise<MermaidViewerHandle>;
@@ -123,8 +123,18 @@ export type PdfAction = {
123
123
  type: 'undo';
124
124
  } | {
125
125
  type: 'redo';
126
- } | {
126
+ }
127
+ /**
128
+ * `savedState` is the page order and annotations that actually reached the
129
+ * file. Builds and writes are async and the document stays editable during
130
+ * one, so the state at completion is not necessarily what was written —
131
+ * omitting it would mark an edit made mid-save as saved and let a host
132
+ * discard it. Omitted = "the current state", which is only correct for a
133
+ * synchronous save.
134
+ */
135
+ | {
127
136
  type: 'mark-saved';
137
+ savedState?: Pick<PdfViewState, 'pageOrder' | 'annotations'>;
128
138
  };
129
139
  export interface PdfController {
130
140
  readonly state: PdfViewState;
@@ -217,7 +217,14 @@ export function createPdfController(pageCount, seed, options = {}) {
217
217
  selectedAnnotationId = action.id;
218
218
  break;
219
219
  case 'mark-saved':
220
- savedSignature = signature();
220
+ // Same shape as `signature()` so an unchanged document still
221
+ // compares equal (state copies preserve key order).
222
+ savedSignature = action.savedState
223
+ ? JSON.stringify({
224
+ pageOrder: action.savedState.pageOrder,
225
+ annotations: action.savedState.annotations
226
+ })
227
+ : signature();
221
228
  break;
222
229
  }
223
230
  if (before && beforeSignature !== signature()) {
@@ -1757,13 +1757,20 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1757
1757
  async function save() {
1758
1758
  if (!ctx.writeback || saveBtn.disabled)
1759
1759
  return;
1760
+ // Pin what is being written before the build starts: only the save/merge
1761
+ // buttons are disabled while an operation runs, so pages and annotations
1762
+ // can still change across the await. Taking the state at completion would
1763
+ // mark those changes as saved and let a host discard them on re-mount.
1764
+ // `stateForSave()` only rasterizes text for output, so the pre-raster
1765
+ // state is what this write represents.
1766
+ const { pageOrder, annotations } = controller.state;
1760
1767
  const result = await runOperation('save', async (control) => {
1761
1768
  const data = await buildOutput(stateForSave(), control);
1762
1769
  await ctx.writeback.write(data);
1763
1770
  });
1764
1771
  if (result.status === 'succeeded') {
1765
1772
  mergedSinceSave = false;
1766
- controller.dispatch({ type: 'mark-saved' });
1773
+ controller.dispatch({ type: 'mark-saved', savedState: { pageOrder, annotations } });
1767
1774
  refreshChrome();
1768
1775
  status.textContent = t('common.savedToOriginal');
1769
1776
  }
@@ -1,9 +1,10 @@
1
- import { diagramViewerCss, type DiagramMountOptions, type DiagramViewerContext } from '../diagram.js';
2
- import type { ViewerHandle, ViewerInput } from '../types.js';
1
+ import { diagramViewerCss, type DiagramMountOptions, type DiagramViewerContext, type DiagramViewerHandle } from '../diagram.js';
2
+ import type { ViewerInput } from '../types.js';
3
3
  export { parsePlantUml } from '../../parsers/plantuml/index.js';
4
4
  export { diagramViewerCss as plantumlViewerCss };
5
5
  export type PlantUmlViewerContext = DiagramViewerContext;
6
6
  export type PlantUmlMountOptions = DiagramMountOptions;
7
+ export type PlantUmlViewerHandle = DiagramViewerHandle;
7
8
  export declare const PLANTUML_VIEWER_META: {
8
9
  id: string;
9
10
  displayNameKey: string;
@@ -13,4 +14,4 @@ export declare const PLANTUML_VIEWER_META: {
13
14
  optionalServices: readonly ["clipboard", "writeback", "save"];
14
15
  inputOwnership: "borrows";
15
16
  };
16
- export declare function mountPlantUmlViewer(input: ViewerInput, container: HTMLElement, ctx: PlantUmlViewerContext, options?: PlantUmlMountOptions): Promise<ViewerHandle>;
17
+ export declare function mountPlantUmlViewer(input: ViewerInput, container: HTMLElement, ctx: PlantUmlViewerContext, options?: PlantUmlMountOptions): Promise<PlantUmlViewerHandle>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-viewer-core",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Shared parsing + rendering core for Omni Viewer (vscode / chrome / obsidian / web)",
5
5
  "type": "module",
6
6
  "license": "MIT",