omni-viewer-core 0.9.0 → 0.10.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.
@@ -2,6 +2,19 @@ import { type ParseOutcome, type ParseOptions } from '../types.js';
2
2
  export interface MarkdownHeading {
3
3
  level: number;
4
4
  text: string;
5
+ /**
6
+ * Source-order handle (`heading-0`, `heading-1`, …), not a DOM anchor.
7
+ *
8
+ * Rendering is owned by the viewer's renderer, which may emit its own slug
9
+ * ids (`#introduction`), and those win over this handle in the preview. The
10
+ * line-scanner also disagrees with a full Markdown parser on which lines are
11
+ * headings — `#` inside a fenced code block is counted here but not rendered,
12
+ * setext headings (`Title\n=====`) are rendered but not counted — so ordinals
13
+ * cannot be assumed to line up with the rendered heading elements either.
14
+ *
15
+ * Build outline links from `start`/`end` (source spans, which are exact) and
16
+ * resolve the rendered target by text or by the renderer's own slugging.
17
+ */
5
18
  id: string;
6
19
  start: number;
7
20
  end: number;
@@ -128,7 +128,7 @@ export const YAML_VIEWER_DESCRIPTOR = {
128
128
  };
129
129
  export const PROTO_VIEWER_DESCRIPTOR = {
130
130
  id: 'proto', displayNameKey: 'proto.title', extensions: ['proto'], priority: 10,
131
- requiredServices: [], optionalServices: ['clipboard']
131
+ requiredServices: [], optionalServices: ['clipboard', 'save', 'writeback']
132
132
  };
133
133
  /**
134
134
  * Excel spans two containers (xlsx/xlsm are zip, xls is ole), so it has no
@@ -1,2 +1,2 @@
1
1
 
2
- :host{display:block;height:100%;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:13px system-ui,sans-serif}.omni-proto{display:flex;flex-direction:column;height:100%;min-height:420px}.omni-proto__header,.omni-proto__toolbar{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);flex-wrap:wrap}.omni-proto__title{font-size:16px;font-weight:650;margin-right:auto}.omni-proto__summary,.omni-proto__muted{color:var(--omni-muted,#999)}.omni-proto button,.omni-proto input,.omni-proto select,.omni-proto textarea{font:inherit;color:inherit;background:var(--omni-control,#2d2d2d);border:1px solid var(--omni-border,#555);border-radius:4px;padding:5px 9px}.omni-proto button{cursor:pointer}.omni-proto button[aria-pressed=true]{background:var(--omni-accent,#0e639c);border-color:var(--omni-accent,#0e639c);color:#fff}.omni-proto__search{min-width:220px}.omni-proto__workspace{display:grid;grid-template-columns:minmax(280px,42%) 1fr;min-height:0;flex:1}.omni-proto__source,.omni-proto__panel{overflow:auto;min-width:0}.omni-proto__source{margin:0;padding:12px 0;border-right:1px solid var(--omni-border,#3c3c3c);font:12px/1.55 ui-monospace,monospace;counter-reset:line}.omni-proto__line{display:block;padding:0 12px 0 52px;white-space:pre;position:relative}.omni-proto__line:before{counter-increment:line;content:counter(line);position:absolute;left:8px;width:34px;text-align:right;color:var(--omni-muted,#777)}.omni-proto__line.is-selected{background:color-mix(in srgb,var(--omni-accent,#0e639c) 30%,transparent)}.omni-proto__tok--kw{color:var(--omni-syntax-keyword,#569cd6)}.omni-proto__tok--type{color:var(--omni-syntax-type,#4ec9b0)}.omni-proto__tok--str{color:var(--omni-syntax-string,#ce9178)}.omni-proto__tok--num{color:var(--omni-syntax-number,#b5cea8)}.omni-proto__tok--comment{color:var(--omni-syntax-comment,#6a9955);font-style:italic}.omni-proto__tok--punct{color:var(--omni-syntax-punct,#808080)}.omni-proto__panel{padding:12px}.omni-proto__row,.omni-proto__card{border:1px solid var(--omni-border,#3c3c3c);border-radius:5px;padding:8px;margin-bottom:7px}.omni-proto__row{display:flex;gap:8px;align-items:baseline}.omni-proto__row[data-line]{cursor:pointer}.omni-proto__badge{font-size:11px;color:var(--omni-accent-fg,#75beff);min-width:58px}.omni-proto__code{font-family:ui-monospace,monospace}.omni-proto__children{margin-left:18px}.omni-proto__baseline{box-sizing:border-box;width:100%;min-height:180px;display:block;margin-bottom:8px;font-family:ui-monospace,monospace}.omni-proto__warning{padding:8px 14px;color:#f0c674;border-top:1px solid var(--omni-border,#3c3c3c)}.omni-proto__empty{color:var(--omni-muted,#999);padding:20px;text-align:center}@media(max-width:760px){.omni-proto__workspace{grid-template-columns:1fr;grid-template-rows:minmax(180px,40%) 1fr}.omni-proto__source{border-right:0;border-bottom:1px solid var(--omni-border,#3c3c3c)}}
2
+ :host{display:block;height:100%;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:13px system-ui,sans-serif}.omni-proto{display:flex;flex-direction:column;height:100%;min-height:420px}.omni-proto__header,.omni-proto__toolbar{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);flex-wrap:wrap}.omni-proto__title{font-size:16px;font-weight:650;margin-right:auto}.omni-proto__summary,.omni-proto__muted{color:var(--omni-muted,#999)}.omni-proto button,.omni-proto input,.omni-proto select,.omni-proto textarea{font:inherit;color:inherit;background:var(--omni-control,#2d2d2d);border:1px solid var(--omni-border,#555);border-radius:4px;padding:5px 9px}.omni-proto button{cursor:pointer}.omni-proto button[aria-pressed=true]{background:var(--omni-accent,#0e639c);border-color:var(--omni-accent,#0e639c);color:#fff}.omni-proto__search{min-width:220px}.omni-proto__workspace{display:grid;grid-template-columns:minmax(280px,42%) 1fr;min-height:0;flex:1}.omni-proto__panel{overflow:auto;min-width:0}.omni-proto__source{position:relative;overflow:hidden;min-width:0;border-right:1px solid var(--omni-border,#3c3c3c)}.omni-proto__highlight{position:absolute;inset:0;margin:0;padding:12px 0;overflow:auto;pointer-events:none;font:12px/1.55 ui-monospace,monospace;counter-reset:line;white-space:pre;color:inherit}.omni-proto textarea.omni-proto__editor{position:absolute;inset:0;box-sizing:border-box;margin:0;padding:12px 12px 12px 52px;background:transparent;border:0;border-radius:0;outline:none;resize:none;overflow:auto;font:12px/1.55 ui-monospace,monospace;white-space:pre;color:transparent;caret-color:var(--omni-fg,#d4d4d4);-webkit-text-fill-color:transparent;z-index:1}.omni-proto__line{display:block;padding:0 12px 0 52px;white-space:pre;position:relative}.omni-proto__line:before{counter-increment:line;content:counter(line);position:absolute;left:8px;width:34px;text-align:right;color:var(--omni-muted,#777)}.omni-proto__line.is-selected{background:color-mix(in srgb,var(--omni-accent,#0e639c) 30%,transparent)}.omni-proto__tok--kw{color:var(--omni-syntax-keyword,#569cd6)}.omni-proto__tok--type{color:var(--omni-syntax-type,#4ec9b0)}.omni-proto__tok--str{color:var(--omni-syntax-string,#ce9178)}.omni-proto__tok--num{color:var(--omni-syntax-number,#b5cea8)}.omni-proto__tok--comment{color:var(--omni-syntax-comment,#6a9955);font-style:italic}.omni-proto__tok--punct{color:var(--omni-syntax-punct,#808080)}.omni-proto__panel{padding:12px}.omni-proto__row,.omni-proto__card{border:1px solid var(--omni-border,#3c3c3c);border-radius:5px;padding:8px;margin-bottom:7px}.omni-proto__row{display:flex;gap:8px;align-items:baseline}.omni-proto__row[data-line]{cursor:pointer}.omni-proto__badge{font-size:11px;color:var(--omni-accent-fg,#75beff);min-width:58px}.omni-proto__code{font-family:ui-monospace,monospace}.omni-proto__children{margin-left:18px}.omni-proto__baseline{box-sizing:border-box;width:100%;min-height:180px;display:block;margin-bottom:8px;font-family:ui-monospace,monospace}.omni-proto__status{margin-left:auto;color:var(--omni-muted,#999);font-size:12px}.omni-proto__warning{padding:8px 14px;color:#f0c674;border-top:1px solid var(--omni-border,#3c3c3c)}.omni-proto__empty{color:var(--omni-muted,#999);padding:20px;text-align:center}@media(max-width:760px){.omni-proto__workspace{grid-template-columns:1fr;grid-template-rows:minmax(180px,40%) 1fr}.omni-proto__source{border-right:0;border-bottom:1px solid var(--omni-border,#3c3c3c)}}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared link hardening for in-document fragment anchors (`<a href="#name">`).
3
+ *
4
+ * Renderers emit intra-document references as fragments: docx-preview turns Word
5
+ * bookmarks into `<span id="name">` and table-of-contents entries, cross-references
6
+ * and footnote back-links into `<a href="#name">`. Viewers strip `href` when they
7
+ * harden the DOM, so the browser's default fragment navigation is gone — and it
8
+ * would not work anyway once the viewer lives in a shadow root, where a
9
+ * document-level fragment lookup cannot see the target.
10
+ */
11
+ /** An anchor's `href`, classified as far as link hardening is concerned. */
12
+ export type AnchorTarget = {
13
+ kind: 'fragment';
14
+ name: string;
15
+ } | {
16
+ kind: 'absolute';
17
+ url: URL;
18
+ } | {
19
+ kind: 'unsupported';
20
+ };
21
+ /**
22
+ * Splits fragment references from everything else. A URL that merely *carries* a
23
+ * fragment (`https://example.com/x#frag`) is an external link, not an in-document
24
+ * one, and stays on the host navigation path.
25
+ */
26
+ export declare function classifyAnchorTarget(href: string): AnchorTarget;
27
+ /**
28
+ * Finds the element a fragment points at, without leaving `scope`.
29
+ *
30
+ * `getElementById` is used rather than a selector because bookmark names are not
31
+ * CSS identifiers — Word emits names that start with a digit, contain `_`, or are
32
+ * non-ASCII — and `CSS.escape` is absent from some hosts and test environments.
33
+ * The lookup is confined to `scope` on purpose: under `styleIsolation: 'scoped'`
34
+ * the root node is the host `document`, where an unrelated page element may share
35
+ * the id. Duplicate ids resolve to the first match in document order.
36
+ */
37
+ export declare function findFragmentTarget(scope: HTMLElement, name: string): HTMLElement | null;
38
+ /**
39
+ * Brings a fragment target into view and moves focus to it.
40
+ *
41
+ * The viewer's own scrollport is adjusted instead of calling `scrollIntoView`,
42
+ * which also scrolls every ancestor scrollport and would drag the host page
43
+ * around when the viewer is embedded in one.
44
+ */
45
+ export declare function revealFragmentTarget(target: HTMLElement, viewport: HTMLElement | null): void;
46
+ /**
47
+ * Wires `anchor` to scroll to its in-document target, registering the teardown on
48
+ * `disposers`. Returns `false` when nothing matches the fragment — a broken
49
+ * cross-reference, which callers surface the same way they surface a blocked link.
50
+ */
51
+ export declare function bindFragmentAnchor(anchor: HTMLElement, name: string, scope: HTMLElement, viewport: HTMLElement | null, disposers: Array<() => void>): boolean;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Shared link hardening for in-document fragment anchors (`<a href="#name">`).
3
+ *
4
+ * Renderers emit intra-document references as fragments: docx-preview turns Word
5
+ * bookmarks into `<span id="name">` and table-of-contents entries, cross-references
6
+ * and footnote back-links into `<a href="#name">`. Viewers strip `href` when they
7
+ * harden the DOM, so the browser's default fragment navigation is gone — and it
8
+ * would not work anyway once the viewer lives in a shadow root, where a
9
+ * document-level fragment lookup cannot see the target.
10
+ */
11
+ /**
12
+ * Splits fragment references from everything else. A URL that merely *carries* a
13
+ * fragment (`https://example.com/x#frag`) is an external link, not an in-document
14
+ * one, and stays on the host navigation path.
15
+ */
16
+ export function classifyAnchorTarget(href) {
17
+ if (href.startsWith('#')) {
18
+ const name = href.slice(1);
19
+ return name ? { kind: 'fragment', name } : { kind: 'unsupported' };
20
+ }
21
+ try {
22
+ return { kind: 'absolute', url: new URL(href) };
23
+ }
24
+ catch {
25
+ return { kind: 'unsupported' };
26
+ }
27
+ }
28
+ /**
29
+ * Finds the element a fragment points at, without leaving `scope`.
30
+ *
31
+ * `getElementById` is used rather than a selector because bookmark names are not
32
+ * CSS identifiers — Word emits names that start with a digit, contain `_`, or are
33
+ * non-ASCII — and `CSS.escape` is absent from some hosts and test environments.
34
+ * The lookup is confined to `scope` on purpose: under `styleIsolation: 'scoped'`
35
+ * the root node is the host `document`, where an unrelated page element may share
36
+ * the id. Duplicate ids resolve to the first match in document order.
37
+ */
38
+ export function findFragmentTarget(scope, name) {
39
+ const root = scope.getRootNode();
40
+ for (const candidate of fragmentCandidates(name)) {
41
+ const byId = root.getElementById?.(candidate) ?? null;
42
+ if (byId && scope.contains(byId))
43
+ return byId;
44
+ // Either the root node has no match, or the match sits outside the viewer;
45
+ // a duplicate may still exist inside it.
46
+ const scoped = [...scope.querySelectorAll('[id]')]
47
+ .find((node) => node.id === candidate);
48
+ if (scoped)
49
+ return scoped;
50
+ }
51
+ return null;
52
+ }
53
+ /** Raw name first, then percent-decoded, since renderers differ on encoding. */
54
+ function fragmentCandidates(name) {
55
+ try {
56
+ const decoded = decodeURIComponent(name);
57
+ return decoded === name ? [name] : [name, decoded];
58
+ }
59
+ catch {
60
+ // Malformed escape sequence: the raw name is all we have.
61
+ return [name];
62
+ }
63
+ }
64
+ /**
65
+ * Brings a fragment target into view and moves focus to it.
66
+ *
67
+ * The viewer's own scrollport is adjusted instead of calling `scrollIntoView`,
68
+ * which also scrolls every ancestor scrollport and would drag the host page
69
+ * around when the viewer is embedded in one.
70
+ */
71
+ export function revealFragmentTarget(target, viewport) {
72
+ if (viewport) {
73
+ viewport.scrollTop +=
74
+ target.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
75
+ }
76
+ else {
77
+ target.scrollIntoView?.({ block: 'start' });
78
+ }
79
+ // Bookmark markers are empty spans, so they need to be made focusable first.
80
+ if (!target.hasAttribute('tabindex'))
81
+ target.setAttribute('tabindex', '-1');
82
+ target.focus({ preventScroll: true });
83
+ }
84
+ /**
85
+ * Wires `anchor` to scroll to its in-document target, registering the teardown on
86
+ * `disposers`. Returns `false` when nothing matches the fragment — a broken
87
+ * cross-reference, which callers surface the same way they surface a blocked link.
88
+ */
89
+ export function bindFragmentAnchor(anchor, name, scope, viewport, disposers) {
90
+ if (!findFragmentTarget(scope, name))
91
+ return false;
92
+ anchor.setAttribute('role', 'link');
93
+ anchor.tabIndex = 0;
94
+ const activate = (event) => {
95
+ event.preventDefault();
96
+ // Resolved on activation rather than up front: viewers prune content after
97
+ // hardening (page limits, re-renders), so the target may be gone by now.
98
+ const target = findFragmentTarget(scope, name);
99
+ if (target)
100
+ revealFragmentTarget(target, viewport);
101
+ };
102
+ const onKeyDown = (event) => {
103
+ const { key } = event;
104
+ if (key === 'Enter' || key === ' ' || key === 'Spacebar')
105
+ activate(event);
106
+ };
107
+ anchor.addEventListener('click', activate);
108
+ anchor.addEventListener('keydown', onKeyDown);
109
+ disposers.push(() => {
110
+ anchor.removeEventListener('click', activate);
111
+ anchor.removeEventListener('keydown', onKeyDown);
112
+ });
113
+ return true;
114
+ }
@@ -1,5 +1,6 @@
1
1
  import { ALLOWED_LINK_SCHEMES } from '../../host/index.js';
2
2
  import { parseMarkdown } from '../../parsers/markdown/index.js';
3
+ import { bindFragmentAnchor, classifyAnchorTarget } from '../anchors.js';
3
4
  import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
4
5
  import { createMarkdownController } from './controller.js';
5
6
  import { maskMathSegments, mathSegmentLiteral } from './math.js';
@@ -156,21 +157,18 @@ export async function mountMarkdownViewer(input, container, ctx, deps, options =
156
157
  preview.querySelectorAll('a').forEach(anchor => {
157
158
  const href = anchor.getAttribute('href') ?? '';
158
159
  anchor.removeAttribute('href');
159
- if (href.startsWith('#')) {
160
- anchor.setAttribute('href', href);
160
+ const target = classifyAnchorTarget(href);
161
+ // Restoring `href` would not scroll anything under the default shadow
162
+ // isolation, where document-level fragment lookup cannot see the target.
163
+ if (target.kind === 'fragment' && bindFragmentAnchor(anchor, target.name, preview, preview, disposers))
161
164
  return;
162
- }
163
- try {
164
- const url = new URL(href);
165
- if (!ALLOWED_LINK_SCHEMES.includes(url.protocol) || !ctx.navigation)
166
- throw new Error('blocked');
167
- anchor.setAttribute('role', 'link');
168
- anchor.tabIndex = 0;
169
- on(anchor, 'click', (() => void ctx.navigation.openExternalUrl(href)));
170
- }
171
- catch {
165
+ if (target.kind !== 'absolute' || !ALLOWED_LINK_SCHEMES.includes(target.url.protocol) || !ctx.navigation) {
172
166
  anchor.setAttribute('aria-disabled', 'true');
167
+ return;
173
168
  }
169
+ anchor.setAttribute('role', 'link');
170
+ anchor.tabIndex = 0;
171
+ on(anchor, 'click', (() => void ctx.navigation.openExternalUrl(href)));
174
172
  });
175
173
  preview.querySelectorAll('img').forEach(image => {
176
174
  const path = image.getAttribute('src') ?? '';
@@ -312,7 +310,22 @@ export async function mountMarkdownViewer(input, container, ctx, deps, options =
312
310
  const holder = new DOMParser().parseFromString(sanitizedHtml, 'text/html');
313
311
  while (holder.body.firstChild)
314
312
  preview.append(holder.body.firstChild);
315
- preview.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((heading, index) => heading.id = `heading-${index}`);
313
+ // Every heading gets a stable id, but a slug the renderer already
314
+ // emitted wins: in-document `#slug` links are written against those.
315
+ // These ids are the preview's own; MarkdownHeading.id from the parser
316
+ // is a source-order handle and is not expected to match them.
317
+ const takenIds = new Set([...preview.querySelectorAll('[id]')].map(node => node.id));
318
+ preview.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((heading, index) => {
319
+ if (heading.id)
320
+ return;
321
+ // A renderer slug can itself be `heading-2`; duplicate ids would send
322
+ // `#heading-2` to whichever element comes first in document order.
323
+ let id = `heading-${index}`;
324
+ for (let suffix = 1; takenIds.has(id); suffix++)
325
+ id = `heading-${index}-${suffix}`;
326
+ heading.id = id;
327
+ takenIds.add(id);
328
+ });
316
329
  hardenContent(version);
317
330
  await renderEnhancements(version);
318
331
  if (disposed || version !== renderVersion)
@@ -1160,6 +1160,10 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1160
1160
  function setMarkupListOpen(open) {
1161
1161
  markupListOpen = open;
1162
1162
  markupList.classList.toggle('is-open', open);
1163
+ // The Annotations panel is a left sidebar that sits directly under the
1164
+ // thumbnail toggle; hide the toggle while it is open so the two do not
1165
+ // overlap.
1166
+ thumbsToggleBtn.hidden = open;
1163
1167
  if (open)
1164
1168
  renderMarkupList();
1165
1169
  }
@@ -1,4 +1,4 @@
1
- import type { ClipboardService, HostContext } from '../../host/index.js';
1
+ import type { ClipboardService, FileSaveService, FileWritebackService, HostContext } from '../../host/index.js';
2
2
  import { type MountOptions, type ViewerHandle, type ViewerInput } from '../types.js';
3
3
  export { protoViewerCss } from './styles.js';
4
4
  export { parseProto } from '../../parsers/proto/index.js';
@@ -8,10 +8,12 @@ export declare const PROTO_VIEWER_META: {
8
8
  extensions: string[];
9
9
  priority: number;
10
10
  requiredServices: readonly [];
11
- optionalServices: readonly ["clipboard"];
11
+ optionalServices: readonly ["clipboard", "save", "writeback"];
12
12
  inputOwnership: "borrows";
13
13
  };
14
14
  export type ProtoViewerContext = HostContext & {
15
15
  clipboard?: ClipboardService;
16
+ save?: FileSaveService;
17
+ writeback?: FileWritebackService;
16
18
  };
17
19
  export declare function mountProtoViewer(input: ViewerInput, container: HTMLElement, ctx: ProtoViewerContext, options?: MountOptions): Promise<ViewerHandle>;
@@ -3,7 +3,7 @@ import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
3
3
  import { protoViewerCss } from './styles.js';
4
4
  export { protoViewerCss } from './styles.js';
5
5
  export { parseProto } from '../../parsers/proto/index.js';
6
- export const PROTO_VIEWER_META = { id: 'proto', displayNameKey: 'proto.title', extensions: ['proto'], priority: 10, requiredServices: [], optionalServices: ['clipboard'], inputOwnership: 'borrows' };
6
+ export const PROTO_VIEWER_META = { id: 'proto', displayNameKey: 'proto.title', extensions: ['proto'], priority: 10, requiredServices: [], optionalServices: ['clipboard', 'save', 'writeback'], inputOwnership: 'borrows' };
7
7
  const PROTO_KEYWORDS = new Set(['syntax', 'package', 'import', 'public', 'weak', 'option', 'message', 'enum', 'service', 'rpc', 'returns', 'stream', 'repeated', 'optional', 'required', 'oneof', 'map', 'reserved', 'extend', 'extensions', 'to', 'max', 'group', 'default']);
8
8
  const PROTO_TYPES = new Set(['double', 'float', 'int32', 'int64', 'uint32', 'uint64', 'sint32', 'sint64', 'fixed32', 'fixed64', 'sfixed32', 'sfixed64', 'bool', 'string', 'bytes']);
9
9
  const PROTO_LITERALS = new Set(['true', 'false']);
@@ -76,7 +76,7 @@ export async function mountProtoViewer(input, container, ctx, options = {}) {
76
76
  if (options.signal?.aborted)
77
77
  throw new MountAbortedError();
78
78
  const source = new TextDecoder().decode(input.data);
79
- const model = parseProto(source, input.fileName);
79
+ let model = parseProto(source, input.fileName);
80
80
  const t = ctx.i18n.t.bind(ctx.i18n);
81
81
  let root;
82
82
  if ((options.styleIsolation ?? 'shadow') === 'shadow' && typeof container.attachShadow === 'function') {
@@ -99,7 +99,9 @@ export async function mountProtoViewer(input, container, ctx, options = {}) {
99
99
  const onPanel = (node, type, listener) => listen(panelDisposers, node, type, listener);
100
100
  const frame = el('div', 'omni-proto');
101
101
  const header = el('header', 'omni-proto__header');
102
- header.append(el('strong', 'omni-proto__title', input.fileName), el('span', 'omni-proto__summary', t('proto.summary', { syntax: model.syntax || 'proto', package: model.packageName || t('proto.noPackage'), messages: model.stats.messages, services: model.stats.services })));
102
+ const summaryText = () => t('proto.summary', { syntax: model.syntax || 'proto', package: model.packageName || t('proto.noPackage'), messages: model.stats.messages, services: model.stats.services });
103
+ const summary = el('span', 'omni-proto__summary', summaryText());
104
+ header.append(el('strong', 'omni-proto__title', input.fileName), summary);
103
105
  const toolbar = el('div', 'omni-proto__toolbar');
104
106
  const search = el('input', 'omni-proto__search');
105
107
  search.type = 'search';
@@ -113,18 +115,34 @@ export async function mountProtoViewer(input, container, ctx, options = {}) {
113
115
  copy.type = 'button';
114
116
  copy.disabled = !ctx.clipboard;
115
117
  copy.title = ctx.clipboard ? '' : t('common.noClipboard');
116
- toolbar.append(copy);
117
- const sourceView = el('pre', 'omni-proto__source');
118
- let inBlock = false;
119
- source.replace(/\r\n?|\n/g, '\n').split('\n').forEach((text, i) => { const line = el('span', 'omni-proto__line'); line.dataset.line = String(i + 1); const result = tokenizeProtoLine(text, inBlock); inBlock = result.inBlock; if (!result.tokens.length)
118
+ const save = el('button', undefined, t('json.save'));
119
+ save.type = 'button';
120
+ const saveAs = el('button', undefined, t('json.saveAs'));
121
+ saveAs.type = 'button';
122
+ const status = el('span', 'omni-proto__status');
123
+ status.setAttribute('aria-live', 'polite');
124
+ toolbar.append(copy, save, saveAs, status);
125
+ const highlight = el('pre', 'omni-proto__highlight');
126
+ const highlightSource = (text) => { highlight.replaceChildren(); let inBlock = false; text.replace(/\r\n?|\n/g, '\n').split('\n').forEach((lineText, i) => { const line = el('span', 'omni-proto__line'); line.dataset.line = String(i + 1); const result = tokenizeProtoLine(lineText, inBlock); inBlock = result.inBlock; if (!result.tokens.length)
120
127
  line.textContent = ' ';
121
128
  else
122
- result.tokens.forEach(token => line.append(token.cls ? el('span', `omni-proto__tok--${token.cls}`, token.text) : document.createTextNode(token.text))); sourceView.append(line); });
129
+ result.tokens.forEach(token => line.append(token.cls ? el('span', `omni-proto__tok--${token.cls}`, token.text) : document.createTextNode(token.text))); highlight.append(line); }); };
130
+ const editor = el('textarea', 'omni-proto__editor');
131
+ editor.spellcheck = false;
132
+ editor.value = source;
133
+ editor.setAttribute('aria-label', t('json.editor.title'));
134
+ let savedText = source;
135
+ let dirty = false;
136
+ const sourceView = el('div', 'omni-proto__source');
137
+ sourceView.append(highlight, editor);
138
+ const syncScroll = () => { highlight.scrollTop = editor.scrollTop; highlight.scrollLeft = editor.scrollLeft; };
139
+ on(editor, 'scroll', syncScroll);
140
+ highlightSource(source);
123
141
  const panel = el('section', 'omni-proto__panel');
124
142
  const workspace = el('main', 'omni-proto__workspace');
125
143
  workspace.append(sourceView, panel);
126
144
  const reveal = (line) => { if (!line)
127
- return; sourceView.querySelectorAll('.is-selected').forEach(node => node.classList.remove('is-selected')); const target = sourceView.querySelector(`[data-line="${line}"]`); target?.classList.add('is-selected'); target?.scrollIntoView({ block: 'center' }); };
145
+ return; highlight.querySelectorAll('.is-selected').forEach(node => node.classList.remove('is-selected')); const target = highlight.querySelector(`[data-line="${line}"]`); target?.classList.add('is-selected'); const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 18; editor.scrollTop = Math.max(0, (line - 1) * lineHeight - editor.clientHeight / 2); syncScroll(); };
128
146
  const row = (badge, text, line) => { const node = el('div', 'omni-proto__row'); node.append(el('span', 'omni-proto__badge', badge), el('span', 'omni-proto__code', text)); node.dataset.search = `${badge} ${text}`.toLowerCase(); if (line) {
129
147
  node.dataset.line = String(line);
130
148
  onPanel(node, 'click', () => reveal(line));
@@ -229,16 +247,46 @@ export async function mountProtoViewer(input, container, ctx, options = {}) {
229
247
  if (!panel.children.length)
230
248
  panel.append(el('div', 'omni-proto__empty', t('proto.noDeclarations')));
231
249
  };
250
+ const warning = el('div', 'omni-proto__warning');
251
+ const updateWarning = () => { warning.textContent = model.warnings.join(' · '); warning.style.display = model.warnings.length ? '' : 'none'; };
252
+ const updateSaveState = () => { save.disabled = !ctx.writeback || !dirty; save.title = ctx.writeback ? '' : t('common.noWriteback'); saveAs.disabled = !ctx.save; saveAs.title = ctx.save ? '' : t('common.noFileSave'); };
253
+ const bytes = () => new TextEncoder().encode(editor.value);
254
+ // Heavy pass: full re-parse + panel rebuild. Debounced (below) so typing in
255
+ // a large schema stays responsive.
256
+ const reparse = () => { model = parseProto(editor.value, input.fileName); summary.textContent = summaryText(); updateWarning(); render(); };
257
+ let reparseTimer;
258
+ // Eager pass on every keystroke: cheap highlight + dirty/save state so the
259
+ // caret feedback and Save button react immediately; the parse/render is
260
+ // deferred to the debounced reparse.
261
+ const onEdit = () => { highlightSource(editor.value); dirty = editor.value !== savedText; status.textContent = ''; updateSaveState(); if (reparseTimer)
262
+ clearTimeout(reparseTimer); reparseTimer = setTimeout(() => { reparseTimer = undefined; reparse(); }, 200); };
263
+ // Snapshot the text before the async write: reading editor.value again in
264
+ // the resolve handler would mark whatever the user typed meanwhile as saved.
265
+ const doSave = () => { if (!ctx.writeback)
266
+ return; const text = editor.value; void ctx.writeback.write(new TextEncoder().encode(text)).then(() => { savedText = text; dirty = editor.value !== text; status.textContent = t('common.savedToOriginal'); updateSaveState(); }).catch(error => { ctx.logger.log('error', `proto save failed: ${String(error)}`); status.textContent = t('common.saveFailed'); }); };
267
+ const doSaveAs = () => { if (!ctx.save)
268
+ return; void Promise.resolve(ctx.save.saveFile(input.fileName, bytes(), 'text/plain')).then(() => { status.textContent = t('common.saved', { name: input.fileName }); }).catch(error => { ctx.logger.log('error', `proto saveAs failed: ${String(error)}`); status.textContent = t('common.saveFailed'); }); };
232
269
  panelDefs.forEach(([key, labelKey]) => { const button = el('button', undefined, t(labelKey)); button.type = 'button'; button.dataset.panel = key; button.setAttribute('aria-pressed', String(key === active)); on(button, 'click', () => { active = key; toolbar.querySelectorAll('[data-panel]').forEach(node => node.setAttribute('aria-pressed', String(node.dataset.panel === active))); render(); }); toolbar.insertBefore(button, copy); });
233
270
  on(search, 'input', () => applySearch(search.value.trim().toLowerCase()));
234
271
  on(copy, 'click', () => { if (ctx.clipboard)
235
272
  void ctx.clipboard.writeText(panel.textContent ?? '').catch(error => ctx.logger.log('error', `proto copy failed: ${String(error)}`)); });
236
- frame.append(header, toolbar, workspace);
237
- if (model.warnings.length)
238
- frame.append(el('div', 'omni-proto__warning', model.warnings.join(' · ')));
273
+ on(editor, 'input', onEdit);
274
+ on(save, 'click', doSave);
275
+ on(saveAs, 'click', doSaveAs);
276
+ on(editor, 'keydown', event => { const keyboard = event; if ((keyboard.metaKey || keyboard.ctrlKey) && keyboard.key === 's') {
277
+ keyboard.preventDefault();
278
+ if (ctx.writeback)
279
+ doSave();
280
+ else
281
+ doSaveAs();
282
+ } });
283
+ updateWarning();
284
+ updateSaveState();
285
+ frame.append(header, toolbar, workspace, warning);
239
286
  root.append(frame);
240
287
  render();
241
- return { dispose() { panelDisposers.splice(0).forEach(dispose => dispose()); disposers.splice(0).forEach(dispose => dispose()); frame.remove(); if (root instanceof ShadowRoot)
288
+ return { dispose() { if (reparseTimer)
289
+ clearTimeout(reparseTimer); panelDisposers.splice(0).forEach(dispose => dispose()); disposers.splice(0).forEach(dispose => dispose()); frame.remove(); if (root instanceof ShadowRoot)
242
290
  root.replaceChildren();
243
291
  else
244
292
  container.classList.remove(VIEWER_ROOT_CLASS, 'omni-viewer--proto'); } };
@@ -1 +1 @@
1
- export declare const protoViewerCss = "\n:host{display:block;height:100%;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:13px system-ui,sans-serif}.omni-proto{display:flex;flex-direction:column;height:100%;min-height:420px}.omni-proto__header,.omni-proto__toolbar{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);flex-wrap:wrap}.omni-proto__title{font-size:16px;font-weight:650;margin-right:auto}.omni-proto__summary,.omni-proto__muted{color:var(--omni-muted,#999)}.omni-proto button,.omni-proto input,.omni-proto select,.omni-proto textarea{font:inherit;color:inherit;background:var(--omni-control,#2d2d2d);border:1px solid var(--omni-border,#555);border-radius:4px;padding:5px 9px}.omni-proto button{cursor:pointer}.omni-proto button[aria-pressed=true]{background:var(--omni-accent,#0e639c);border-color:var(--omni-accent,#0e639c);color:#fff}.omni-proto__search{min-width:220px}.omni-proto__workspace{display:grid;grid-template-columns:minmax(280px,42%) 1fr;min-height:0;flex:1}.omni-proto__source,.omni-proto__panel{overflow:auto;min-width:0}.omni-proto__source{margin:0;padding:12px 0;border-right:1px solid var(--omni-border,#3c3c3c);font:12px/1.55 ui-monospace,monospace;counter-reset:line}.omni-proto__line{display:block;padding:0 12px 0 52px;white-space:pre;position:relative}.omni-proto__line:before{counter-increment:line;content:counter(line);position:absolute;left:8px;width:34px;text-align:right;color:var(--omni-muted,#777)}.omni-proto__line.is-selected{background:color-mix(in srgb,var(--omni-accent,#0e639c) 30%,transparent)}.omni-proto__tok--kw{color:var(--omni-syntax-keyword,#569cd6)}.omni-proto__tok--type{color:var(--omni-syntax-type,#4ec9b0)}.omni-proto__tok--str{color:var(--omni-syntax-string,#ce9178)}.omni-proto__tok--num{color:var(--omni-syntax-number,#b5cea8)}.omni-proto__tok--comment{color:var(--omni-syntax-comment,#6a9955);font-style:italic}.omni-proto__tok--punct{color:var(--omni-syntax-punct,#808080)}.omni-proto__panel{padding:12px}.omni-proto__row,.omni-proto__card{border:1px solid var(--omni-border,#3c3c3c);border-radius:5px;padding:8px;margin-bottom:7px}.omni-proto__row{display:flex;gap:8px;align-items:baseline}.omni-proto__row[data-line]{cursor:pointer}.omni-proto__badge{font-size:11px;color:var(--omni-accent-fg,#75beff);min-width:58px}.omni-proto__code{font-family:ui-monospace,monospace}.omni-proto__children{margin-left:18px}.omni-proto__baseline{box-sizing:border-box;width:100%;min-height:180px;display:block;margin-bottom:8px;font-family:ui-monospace,monospace}.omni-proto__warning{padding:8px 14px;color:#f0c674;border-top:1px solid var(--omni-border,#3c3c3c)}.omni-proto__empty{color:var(--omni-muted,#999);padding:20px;text-align:center}@media(max-width:760px){.omni-proto__workspace{grid-template-columns:1fr;grid-template-rows:minmax(180px,40%) 1fr}.omni-proto__source{border-right:0;border-bottom:1px solid var(--omni-border,#3c3c3c)}}";
1
+ export declare const protoViewerCss = "\n:host{display:block;height:100%;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:13px system-ui,sans-serif}.omni-proto{display:flex;flex-direction:column;height:100%;min-height:420px}.omni-proto__header,.omni-proto__toolbar{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);flex-wrap:wrap}.omni-proto__title{font-size:16px;font-weight:650;margin-right:auto}.omni-proto__summary,.omni-proto__muted{color:var(--omni-muted,#999)}.omni-proto button,.omni-proto input,.omni-proto select,.omni-proto textarea{font:inherit;color:inherit;background:var(--omni-control,#2d2d2d);border:1px solid var(--omni-border,#555);border-radius:4px;padding:5px 9px}.omni-proto button{cursor:pointer}.omni-proto button[aria-pressed=true]{background:var(--omni-accent,#0e639c);border-color:var(--omni-accent,#0e639c);color:#fff}.omni-proto__search{min-width:220px}.omni-proto__workspace{display:grid;grid-template-columns:minmax(280px,42%) 1fr;min-height:0;flex:1}.omni-proto__panel{overflow:auto;min-width:0}.omni-proto__source{position:relative;overflow:hidden;min-width:0;border-right:1px solid var(--omni-border,#3c3c3c)}.omni-proto__highlight{position:absolute;inset:0;margin:0;padding:12px 0;overflow:auto;pointer-events:none;font:12px/1.55 ui-monospace,monospace;counter-reset:line;white-space:pre;color:inherit}.omni-proto textarea.omni-proto__editor{position:absolute;inset:0;box-sizing:border-box;margin:0;padding:12px 12px 12px 52px;background:transparent;border:0;border-radius:0;outline:none;resize:none;overflow:auto;font:12px/1.55 ui-monospace,monospace;white-space:pre;color:transparent;caret-color:var(--omni-fg,#d4d4d4);-webkit-text-fill-color:transparent;z-index:1}.omni-proto__line{display:block;padding:0 12px 0 52px;white-space:pre;position:relative}.omni-proto__line:before{counter-increment:line;content:counter(line);position:absolute;left:8px;width:34px;text-align:right;color:var(--omni-muted,#777)}.omni-proto__line.is-selected{background:color-mix(in srgb,var(--omni-accent,#0e639c) 30%,transparent)}.omni-proto__tok--kw{color:var(--omni-syntax-keyword,#569cd6)}.omni-proto__tok--type{color:var(--omni-syntax-type,#4ec9b0)}.omni-proto__tok--str{color:var(--omni-syntax-string,#ce9178)}.omni-proto__tok--num{color:var(--omni-syntax-number,#b5cea8)}.omni-proto__tok--comment{color:var(--omni-syntax-comment,#6a9955);font-style:italic}.omni-proto__tok--punct{color:var(--omni-syntax-punct,#808080)}.omni-proto__panel{padding:12px}.omni-proto__row,.omni-proto__card{border:1px solid var(--omni-border,#3c3c3c);border-radius:5px;padding:8px;margin-bottom:7px}.omni-proto__row{display:flex;gap:8px;align-items:baseline}.omni-proto__row[data-line]{cursor:pointer}.omni-proto__badge{font-size:11px;color:var(--omni-accent-fg,#75beff);min-width:58px}.omni-proto__code{font-family:ui-monospace,monospace}.omni-proto__children{margin-left:18px}.omni-proto__baseline{box-sizing:border-box;width:100%;min-height:180px;display:block;margin-bottom:8px;font-family:ui-monospace,monospace}.omni-proto__status{margin-left:auto;color:var(--omni-muted,#999);font-size:12px}.omni-proto__warning{padding:8px 14px;color:#f0c674;border-top:1px solid var(--omni-border,#3c3c3c)}.omni-proto__empty{color:var(--omni-muted,#999);padding:20px;text-align:center}@media(max-width:760px){.omni-proto__workspace{grid-template-columns:1fr;grid-template-rows:minmax(180px,40%) 1fr}.omni-proto__source{border-right:0;border-bottom:1px solid var(--omni-border,#3c3c3c)}}";
@@ -1,2 +1,2 @@
1
1
  export const protoViewerCss = `
2
- :host{display:block;height:100%;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:13px system-ui,sans-serif}.omni-proto{display:flex;flex-direction:column;height:100%;min-height:420px}.omni-proto__header,.omni-proto__toolbar{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);flex-wrap:wrap}.omni-proto__title{font-size:16px;font-weight:650;margin-right:auto}.omni-proto__summary,.omni-proto__muted{color:var(--omni-muted,#999)}.omni-proto button,.omni-proto input,.omni-proto select,.omni-proto textarea{font:inherit;color:inherit;background:var(--omni-control,#2d2d2d);border:1px solid var(--omni-border,#555);border-radius:4px;padding:5px 9px}.omni-proto button{cursor:pointer}.omni-proto button[aria-pressed=true]{background:var(--omni-accent,#0e639c);border-color:var(--omni-accent,#0e639c);color:#fff}.omni-proto__search{min-width:220px}.omni-proto__workspace{display:grid;grid-template-columns:minmax(280px,42%) 1fr;min-height:0;flex:1}.omni-proto__source,.omni-proto__panel{overflow:auto;min-width:0}.omni-proto__source{margin:0;padding:12px 0;border-right:1px solid var(--omni-border,#3c3c3c);font:12px/1.55 ui-monospace,monospace;counter-reset:line}.omni-proto__line{display:block;padding:0 12px 0 52px;white-space:pre;position:relative}.omni-proto__line:before{counter-increment:line;content:counter(line);position:absolute;left:8px;width:34px;text-align:right;color:var(--omni-muted,#777)}.omni-proto__line.is-selected{background:color-mix(in srgb,var(--omni-accent,#0e639c) 30%,transparent)}.omni-proto__tok--kw{color:var(--omni-syntax-keyword,#569cd6)}.omni-proto__tok--type{color:var(--omni-syntax-type,#4ec9b0)}.omni-proto__tok--str{color:var(--omni-syntax-string,#ce9178)}.omni-proto__tok--num{color:var(--omni-syntax-number,#b5cea8)}.omni-proto__tok--comment{color:var(--omni-syntax-comment,#6a9955);font-style:italic}.omni-proto__tok--punct{color:var(--omni-syntax-punct,#808080)}.omni-proto__panel{padding:12px}.omni-proto__row,.omni-proto__card{border:1px solid var(--omni-border,#3c3c3c);border-radius:5px;padding:8px;margin-bottom:7px}.omni-proto__row{display:flex;gap:8px;align-items:baseline}.omni-proto__row[data-line]{cursor:pointer}.omni-proto__badge{font-size:11px;color:var(--omni-accent-fg,#75beff);min-width:58px}.omni-proto__code{font-family:ui-monospace,monospace}.omni-proto__children{margin-left:18px}.omni-proto__baseline{box-sizing:border-box;width:100%;min-height:180px;display:block;margin-bottom:8px;font-family:ui-monospace,monospace}.omni-proto__warning{padding:8px 14px;color:#f0c674;border-top:1px solid var(--omni-border,#3c3c3c)}.omni-proto__empty{color:var(--omni-muted,#999);padding:20px;text-align:center}@media(max-width:760px){.omni-proto__workspace{grid-template-columns:1fr;grid-template-rows:minmax(180px,40%) 1fr}.omni-proto__source{border-right:0;border-bottom:1px solid var(--omni-border,#3c3c3c)}}`;
2
+ :host{display:block;height:100%;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:13px system-ui,sans-serif}.omni-proto{display:flex;flex-direction:column;height:100%;min-height:420px}.omni-proto__header,.omni-proto__toolbar{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);flex-wrap:wrap}.omni-proto__title{font-size:16px;font-weight:650;margin-right:auto}.omni-proto__summary,.omni-proto__muted{color:var(--omni-muted,#999)}.omni-proto button,.omni-proto input,.omni-proto select,.omni-proto textarea{font:inherit;color:inherit;background:var(--omni-control,#2d2d2d);border:1px solid var(--omni-border,#555);border-radius:4px;padding:5px 9px}.omni-proto button{cursor:pointer}.omni-proto button[aria-pressed=true]{background:var(--omni-accent,#0e639c);border-color:var(--omni-accent,#0e639c);color:#fff}.omni-proto__search{min-width:220px}.omni-proto__workspace{display:grid;grid-template-columns:minmax(280px,42%) 1fr;min-height:0;flex:1}.omni-proto__panel{overflow:auto;min-width:0}.omni-proto__source{position:relative;overflow:hidden;min-width:0;border-right:1px solid var(--omni-border,#3c3c3c)}.omni-proto__highlight{position:absolute;inset:0;margin:0;padding:12px 0;overflow:auto;pointer-events:none;font:12px/1.55 ui-monospace,monospace;counter-reset:line;white-space:pre;color:inherit}.omni-proto textarea.omni-proto__editor{position:absolute;inset:0;box-sizing:border-box;margin:0;padding:12px 12px 12px 52px;background:transparent;border:0;border-radius:0;outline:none;resize:none;overflow:auto;font:12px/1.55 ui-monospace,monospace;white-space:pre;color:transparent;caret-color:var(--omni-fg,#d4d4d4);-webkit-text-fill-color:transparent;z-index:1}.omni-proto__line{display:block;padding:0 12px 0 52px;white-space:pre;position:relative}.omni-proto__line:before{counter-increment:line;content:counter(line);position:absolute;left:8px;width:34px;text-align:right;color:var(--omni-muted,#777)}.omni-proto__line.is-selected{background:color-mix(in srgb,var(--omni-accent,#0e639c) 30%,transparent)}.omni-proto__tok--kw{color:var(--omni-syntax-keyword,#569cd6)}.omni-proto__tok--type{color:var(--omni-syntax-type,#4ec9b0)}.omni-proto__tok--str{color:var(--omni-syntax-string,#ce9178)}.omni-proto__tok--num{color:var(--omni-syntax-number,#b5cea8)}.omni-proto__tok--comment{color:var(--omni-syntax-comment,#6a9955);font-style:italic}.omni-proto__tok--punct{color:var(--omni-syntax-punct,#808080)}.omni-proto__panel{padding:12px}.omni-proto__row,.omni-proto__card{border:1px solid var(--omni-border,#3c3c3c);border-radius:5px;padding:8px;margin-bottom:7px}.omni-proto__row{display:flex;gap:8px;align-items:baseline}.omni-proto__row[data-line]{cursor:pointer}.omni-proto__badge{font-size:11px;color:var(--omni-accent-fg,#75beff);min-width:58px}.omni-proto__code{font-family:ui-monospace,monospace}.omni-proto__children{margin-left:18px}.omni-proto__baseline{box-sizing:border-box;width:100%;min-height:180px;display:block;margin-bottom:8px;font-family:ui-monospace,monospace}.omni-proto__status{margin-left:auto;color:var(--omni-muted,#999);font-size:12px}.omni-proto__warning{padding:8px 14px;color:#f0c674;border-top:1px solid var(--omni-border,#3c3c3c)}.omni-proto__empty{color:var(--omni-muted,#999);padding:20px;text-align:center}@media(max-width:760px){.omni-proto__workspace{grid-template-columns:1fr;grid-template-rows:minmax(180px,40%) 1fr}.omni-proto__source{border-right:0;border-bottom:1px solid var(--omni-border,#3c3c3c)}}`;
@@ -141,7 +141,7 @@ export async function preprocessDocx(data, zipModule, sheetOrOptions, legacyMaxD
141
141
  messageKey: 'diag.word.embedded-limit',
142
142
  ...(relation ? { location: relation.target } : {})
143
143
  });
144
- output += '<w:r/>';
144
+ // Dropping the drawing outright: an empty run here would nest inside one.
145
145
  changed = true;
146
146
  continue;
147
147
  }
@@ -157,7 +157,7 @@ export async function preprocessDocx(data, zipModule, sheetOrOptions, legacyMaxD
157
157
  }
158
158
  const token = `__OMNI_WORD_CHART_${index++}__`;
159
159
  placeholders.push({ token, kind: 'chart', chart });
160
- output += `<w:r><w:t xml:space="preserve">${token}</w:t></w:r>`;
160
+ output += runInnerText(token);
161
161
  changed = true;
162
162
  }
163
163
  documentXml = output + documentXml.slice(cursor);
@@ -187,7 +187,7 @@ export async function preprocessDocx(data, zipModule, sheetOrOptions, legacyMaxD
187
187
  messageKey: 'diag.word.embedded-limit',
188
188
  ...(relation ? { location: relation.target } : {})
189
189
  });
190
- output += '<w:r/>';
190
+ // Dropping the object outright: an empty run here would nest inside one.
191
191
  changed = true;
192
192
  continue;
193
193
  }
@@ -244,7 +244,7 @@ export async function preprocessDocx(data, zipModule, sheetOrOptions, legacyMaxD
244
244
  .map((row) => row.slice(0, 10).map((cell) => String(cell ?? '')));
245
245
  const token = `__OMNI_WORD_SHEET_${index++}__`;
246
246
  placeholders.push({ token, kind: 'sheet', title: name, rows });
247
- output += `<w:r><w:t xml:space="preserve">${token}</w:t></w:r>`;
247
+ output += runInnerText(token);
248
248
  changed = true;
249
249
  }
250
250
  catch (error) {
@@ -273,6 +273,16 @@ export async function preprocessDocx(data, zipModule, sheetOrOptions, legacyMaxD
273
273
  }
274
274
  return { data, placeholders, diagnostics, partial };
275
275
  }
276
+ /**
277
+ * Placeholder markup substituted for a `w:drawing`/`w:object`.
278
+ *
279
+ * Both are run *inner* content, so the replacement is a bare `w:t` — wrapping it
280
+ * in another `w:r` would nest a run inside a run, which docx-preview's run parser
281
+ * does not recognise and silently drops along with the placeholder token.
282
+ */
283
+ function runInnerText(token) {
284
+ return `<w:t xml:space="preserve">${token}</w:t>`;
285
+ }
276
286
  export function parseChart(xml) {
277
287
  const sourceType = xml.match(/<c:([A-Za-z0-9]+Chart)\b/)?.[1]
278
288
  ?? (/<c:ser>/i.test(xml) ? 'barChart' : '');
@@ -1,4 +1,5 @@
1
1
  import { ALLOWED_LINK_SCHEMES } from '../../host/index.js';
2
+ import { bindFragmentAnchor, classifyAnchorTarget } from '../anchors.js';
2
3
  import { hasEmbeddedDocWorkbook, parseDocBinaryHtml } from '../../parsers/doc-binary/index.js';
3
4
  import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
4
5
  import { createWordController } from './controller.js';
@@ -349,7 +350,7 @@ export async function mountWordViewer(input, container, ctx, deps = {}, options
349
350
  paginateLegacyDocument(content);
350
351
  }
351
352
  ensureActive();
352
- const securityDiagnostics = secureWordDom(content, ctx, disposers);
353
+ const securityDiagnostics = secureWordDom(content, ctx, disposers, viewport);
353
354
  if (securityDiagnostics.length) {
354
355
  diagnostics.push(...securityDiagnostics);
355
356
  partial = true;
@@ -440,7 +441,7 @@ export async function mountWordViewer(input, container, ctx, deps = {}, options
440
441
  messageKey: 'diag.word.fallback-renderer-used',
441
442
  recoverable: true
442
443
  });
443
- diagnostics.push(...secureWordDom(content, ctx, disposers));
444
+ diagnostics.push(...secureWordDom(content, ctx, disposers, viewport));
444
445
  emitStatus({
445
446
  state: 'ready',
446
447
  format,
@@ -770,7 +771,7 @@ function pieColors(chart) {
770
771
  const first = chart.series[0]?.color;
771
772
  return first ? [first, ...palette.filter((color) => color !== first)] : palette;
772
773
  }
773
- function secureWordDom(root, ctx, disposers) {
774
+ function secureWordDom(root, ctx, disposers, viewport) {
774
775
  const diagnostics = [];
775
776
  root.querySelectorAll('img,source,video,audio').forEach((node) => {
776
777
  for (const attr of ['src', 'srcset']) {
@@ -791,13 +792,14 @@ function secureWordDom(root, ctx, disposers) {
791
792
  const href = anchor.getAttribute('href') ?? '';
792
793
  anchor.removeAttribute('href');
793
794
  anchor.removeAttribute('target');
794
- let allowed = false;
795
- try {
796
- allowed = ALLOWED_LINK_SCHEMES.includes(new URL(href).protocol);
797
- }
798
- catch {
799
- allowed = false;
800
- }
795
+ const target = classifyAnchorTarget(href);
796
+ // Bookmarks, table-of-contents entries and note back-references stay usable
797
+ // without a navigation service: they never leave the document.
798
+ if (target.kind === 'fragment' &&
799
+ bindFragmentAnchor(anchor, target.name, root, viewport, disposers))
800
+ return;
801
+ const allowed = target.kind === 'absolute' &&
802
+ ALLOWED_LINK_SCHEMES.includes(target.url.protocol);
801
803
  if (!allowed || !ctx.navigation) {
802
804
  anchor.setAttribute('aria-disabled', 'true');
803
805
  if (/^(?:https?:)?\/\//i.test(href)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-viewer-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Shared parsing + rendering core for Omni Viewer (vscode / chrome / obsidian / web)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,2 +0,0 @@
1
- import type { HwpViewerDeps } from './index.js';
2
- export declare function loadHwpViewerDeps(): Promise<HwpViewerDeps>;
@@ -1,6 +0,0 @@
1
- export async function loadHwpViewerDeps() {
2
- const imported = await import('@rhwp/core');
3
- if (typeof imported.default === 'function')
4
- await imported.default();
5
- return { loadRhwp: async () => imported };
6
- }