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.
- package/dist/parsers/markdown/index.d.ts +13 -0
- package/dist/registry/index.js +1 -1
- package/dist/styles/proto.css +1 -1
- package/dist/viewers/anchors.d.ts +51 -0
- package/dist/viewers/anchors.js +114 -0
- package/dist/viewers/markdown/index.js +26 -13
- package/dist/viewers/pdf/index.js +4 -0
- package/dist/viewers/proto/index.d.ts +4 -2
- package/dist/viewers/proto/index.js +61 -13
- package/dist/viewers/proto/styles.d.ts +1 -1
- package/dist/viewers/proto/styles.js +1 -1
- package/dist/viewers/word/docx-preprocess.js +14 -4
- package/dist/viewers/word/index.js +12 -10
- package/package.json +1 -1
- package/dist/viewers/hwp/self-loading.d.ts +0 -2
- package/dist/viewers/hwp/self-loading.js +0 -6
|
@@ -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;
|
package/dist/registry/index.js
CHANGED
|
@@ -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
|
package/dist/styles/proto.css
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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)));
|
|
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;
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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() {
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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