omni-viewer-core 0.1.0 → 0.3.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/i18n/catalog.de.d.ts +1 -0
- package/dist/i18n/catalog.de.js +68 -0
- package/dist/i18n/catalog.en.js +3 -0
- package/dist/i18n/catalog.fr.d.ts +1 -0
- package/dist/i18n/catalog.fr.js +68 -0
- package/dist/i18n/catalog.it.d.ts +1 -0
- package/dist/i18n/catalog.it.js +68 -0
- package/dist/i18n/catalog.ja.d.ts +1 -0
- package/dist/i18n/catalog.ja.js +288 -0
- package/dist/i18n/catalog.ko.js +372 -26
- package/dist/i18n/catalog.th.d.ts +1 -0
- package/dist/i18n/catalog.th.js +68 -0
- package/dist/i18n/catalog.zh-cn.d.ts +1 -0
- package/dist/i18n/catalog.zh-cn.js +265 -0
- package/dist/i18n/catalog.zh-tw.d.ts +1 -0
- package/dist/i18n/catalog.zh-tw.js +52 -0
- package/dist/i18n/index.d.ts +7 -0
- package/dist/i18n/index.js +35 -2
- package/dist/parsers/archive/index.d.ts +16 -2
- package/dist/parsers/archive/index.js +39 -11
- package/dist/parsers/archive/model.d.ts +20 -0
- package/dist/parsers/audio/index.d.ts +3 -0
- package/dist/parsers/audio/index.js +5 -0
- package/dist/parsers/pptx/index.d.ts +2 -1
- package/dist/parsers/pptx/index.js +5 -2
- package/dist/parsers/video/index.d.ts +3 -0
- package/dist/parsers/video/index.js +3 -0
- package/dist/styles/archive.css +1 -0
- package/dist/styles/audio.css +8 -0
- package/dist/styles/ppt.css +1 -1
- package/dist/viewers/archive/index.d.ts +17 -1
- package/dist/viewers/archive/index.js +124 -21
- package/dist/viewers/archive/styles.d.ts +1 -1
- package/dist/viewers/archive/styles.js +1 -0
- package/dist/viewers/audio/styles.js +8 -0
- package/dist/viewers/pdf/editing.d.ts +6 -4
- package/dist/viewers/pdf/editing.js +47 -13
- package/dist/viewers/pdf/index.js +11 -10
- package/dist/viewers/ppt/chart.js +33 -7
- package/dist/viewers/ppt/render.js +53 -8
- package/dist/viewers/ppt/styles.d.ts +1 -1
- package/dist/viewers/ppt/styles.js +1 -1
- package/package.json +2 -2
- package/dist/viewers/hwp/self-loading.d.ts +0 -2
- package/dist/viewers/hwp/self-loading.js +0 -6
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
const MIME = { mp4: 'video/mp4', mts: 'video/mp2t', m2ts: 'video/mp2t', avi: 'video/x-msvideo', mov: 'video/quicktime', wmv: 'video/x-ms-wmv', flv: 'video/x-flv', webm: 'video/webm', mkv: 'video/x-matroska' };
|
|
2
2
|
export function parseVideoInfo(fileName, data) { const ext = fileName.toLowerCase().split('.').pop() ?? ''; return { format: ext.toUpperCase() || 'VIDEO', mimeType: MIME[ext] ?? 'application/octet-stream', warnings: data.byteLength ? [] : ['The video file is empty.'] }; }
|
|
3
|
+
/** Returns the video MIME type for a file name's extension, or undefined when
|
|
4
|
+
* the extension is not a recognized video format. */
|
|
5
|
+
export function videoMimeType(fileName) { return MIME[fileName.toLowerCase().split('.').pop() ?? '']; }
|
package/dist/styles/archive.css
CHANGED
|
@@ -7,4 +7,5 @@
|
|
|
7
7
|
.omni-archive__workspace{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(320px,.95fr);gap:16px;align-items:start}.omni-archive__table-wrap{overflow:auto;min-width:0;max-height:70vh}.omni-archive__table{width:100%;table-layout:fixed;border-collapse:collapse}.omni-archive__table th,.omni-archive__table td{padding:12px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);text-align:left;vertical-align:top;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-archive__table th:first-child{width:42%}.omni-archive__table th{position:sticky;top:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);background:var(--omni-bg-secondary,#252526);z-index:1}.omni-archive__path{font-family:var(--omni-mono-font,"SFMono-Regular",Consolas,monospace)}.omni-archive__entry{height:43px;cursor:pointer;transition:background-color 120ms ease}.omni-archive__entry:hover{background:color-mix(in srgb,var(--omni-accent,#d97706) 8%,transparent)}.omni-archive__entry:focus-visible{outline:2px solid var(--omni-accent,#d97706);outline-offset:-2px}.omni-archive__entry.is-selected{background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent)}.omni-archive__empty{padding:26px 18px;text-align:center;color:var(--omni-muted-fg,#9d9d9d)}
|
|
8
8
|
.omni-archive__preview-panel{padding:18px;min-height:420px;display:flex;flex-direction:column;gap:14px}.omni-archive__preview-header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.omni-archive__preview-kicker{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__preview-header h2{margin:0;font-size:18px;line-height:1.35;word-break:break-word}.omni-archive__preview-badge{border-radius:999px;padding:8px 12px;font-size:12px;font-weight:700;background:var(--omni-badge-bg,#3a3d41);white-space:nowrap}.omni-archive__preview-meta{margin:0}.omni-archive__preview{margin:0;flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;color:var(--omni-fg,#d4d4d4);font:12px/1.55 var(--omni-mono-font,"SFMono-Regular",Consolas,monospace)}
|
|
9
9
|
.omni-archive__save{align-self:flex-start;padding:8px 12px;border:1px solid var(--omni-border,#3c3c3c);border-radius:6px;background:var(--omni-button-bg,#3a3d41);color:var(--omni-fg,#d4d4d4);font:inherit;cursor:pointer}.omni-archive__save:hover:not(:disabled){background:var(--omni-button-hover-bg,#45494e)}.omni-archive__save:disabled{opacity:.55;cursor:not-allowed}.omni-archive__spacer td{padding:0!important;border:0!important}
|
|
10
|
+
.omni-archive__preview-media{flex:1;display:flex;align-items:center;justify-content:center;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;overflow:auto}.omni-archive__audio{width:100%}.omni-archive__video{max-width:100%;max-height:100%}.omni-archive__image{max-width:100%;max-height:100%;object-fit:contain}
|
|
10
11
|
@media(max-width:720px){.omni-viewer--archive{padding:16px 10px 24px}.omni-archive__hero{flex-direction:column}.omni-archive__pills{justify-content:flex-start}.omni-archive__table th:nth-child(n+3),.omni-archive__table td:nth-child(n+3){display:none}.omni-archive__workspace{grid-template-columns:1fr}.omni-archive__preview-panel{min-height:280px}}
|
package/dist/styles/audio.css
CHANGED
|
@@ -55,6 +55,14 @@
|
|
|
55
55
|
.omni-audio__spectrogram { min-height: 0; }
|
|
56
56
|
.omni-audio__spectrogram--active { min-height: 200px; }
|
|
57
57
|
|
|
58
|
+
/* Keep the WaveSurfer playhead (cursor + played-region overlay) pinned to the
|
|
59
|
+
waveform strip. Some wavesurfer spectrogram builds render the spectrogram
|
|
60
|
+
inside the main wrapper instead of our dedicated container; without this the
|
|
61
|
+
full-height playhead sweeps down across the spectrogram ("progress bar"
|
|
62
|
+
running along the bottom) rather than staying with the waveform at the top. */
|
|
63
|
+
.omni-audio__waveform ::part(cursor),
|
|
64
|
+
.omni-audio__waveform ::part(progress) { height: 128px !important; }
|
|
65
|
+
|
|
58
66
|
.omni-audio__status { color: var(--omni-muted, #9d9d9d); font-size: 12px; min-height: 16px; }
|
|
59
67
|
|
|
60
68
|
.omni-audio__loading { display: flex; justify-content: center; align-items: center; min-height: 120px; color: var(--omni-muted, #9d9d9d); }
|
package/dist/styles/ppt.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
:host,.omni-viewer--ppt{color:var(--omni-fg,#ddd);font:13px system-ui}.omni-ppt{height:100%;display:flex;flex-direction:column;background:var(--omni-bg,#181818)}.omni-ppt__toolbar{display:flex;gap:6px;align-items:center;padding:8px;border-bottom:1px solid var(--omni-border,#444)}.omni-ppt__toolbar button,.omni-ppt__toolbar select{background:var(--omni-control-bg,#292929);color:inherit;border:1px solid var(--omni-border,#555);border-radius:4px;padding:4px 8px}.omni-ppt__viewport{overflow:auto;flex:1;padding:20px}.omni-ppt__slides{display:flex;flex-direction:column;align-items:center;gap:20px}.omni-ppt__slide{position:relative;overflow:hidden;box-shadow:0 2px 12px #0008;transform-origin:top center;flex:none}.omni-ppt__element{position:absolute;box-sizing:border-box;overflow:hidden}.omni-ppt__text{white-space:pre-wrap}.omni-ppt__text p{margin:0}.omni-ppt__image{width:100%;height:100%;object-fit:
|
|
1
|
+
:host,.omni-viewer--ppt{color:var(--omni-fg,#ddd);font:13px system-ui}.omni-ppt{height:100%;display:flex;flex-direction:column;background:var(--omni-bg,#181818)}.omni-ppt__toolbar{display:flex;gap:6px;align-items:center;padding:8px;border-bottom:1px solid var(--omni-border,#444)}.omni-ppt__toolbar button,.omni-ppt__toolbar select{background:var(--omni-control-bg,#292929);color:inherit;border:1px solid var(--omni-border,#555);border-radius:4px;padding:4px 8px}.omni-ppt__viewport{overflow:auto;flex:1;padding:20px}.omni-ppt__slides{display:flex;flex-direction:column;align-items:center;gap:20px}.omni-ppt__slide{position:relative;overflow:hidden;border-radius:6px;box-shadow:0 2px 12px #0008;transform-origin:top center;flex:none}.omni-ppt__element{position:absolute;box-sizing:border-box;overflow:hidden}.omni-ppt__text{padding:2px;white-space:pre-wrap;word-break:break-word}.omni-ppt__text p{margin:0 0 4px}.omni-ppt__text[data-text-preset="cover-title"] .omni-ppt__text-line,.omni-ppt__text[data-text-preset="cover-subtitle"] .omni-ppt__text-line{margin-bottom:0}.omni-ppt__image{width:100%;height:100%;object-fit:cover;object-position:center}.omni-ppt__table-frame{background:#fff;border:1px solid #bdbdbd;overflow:auto}.omni-ppt__table{border-collapse:collapse;width:100%;font-size:12px;color:#111}.omni-ppt__table th,.omni-ppt__table td{border:1px solid #d3d3d3;padding:4px 6px;vertical-align:top}.omni-ppt__chart{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:#f7f7f7;border:1px dashed #9a9a9a;color:#333}.omni-ppt__chart--rendered{background:transparent;border:0}.omni-ppt__chart-title{font-size:14px;font-weight:700;margin-bottom:4px}.omni-ppt__chart-subtitle{font-size:12px;opacity:.8}.omni-ppt__shape{width:100%;height:100%}.omni-ppt__empty{padding:32px;text-align:center}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FileSaveService, HostContext } from '../../host/index.js';
|
|
2
|
-
import { type ArchiveDecoder } from '../../parsers/archive/index.js';
|
|
2
|
+
import { type ArchiveDecoder, type ArchiveEntrySaver, type ArchiveStreamDecoder } from '../../parsers/archive/index.js';
|
|
3
3
|
import type { ResourceLimits } from '../../parsers/types.js';
|
|
4
4
|
import { type MountOptions, type ViewerHandle, type ViewerInput } from '../types.js';
|
|
5
5
|
export * from '../../parsers/archive/index.js';
|
|
@@ -17,9 +17,25 @@ export declare const ARCHIVE_VIEWER_META: {
|
|
|
17
17
|
export interface ArchiveViewerDeps extends ArchiveDecoder {
|
|
18
18
|
}
|
|
19
19
|
export type ArchiveViewerContext = HostContext & {
|
|
20
|
+
/** Buffered save: core extracts the entry and hands over its bytes. */
|
|
20
21
|
save?: FileSaveService;
|
|
22
|
+
/** Streaming save: the adapter pipes the entry to disk itself, so a
|
|
23
|
+
* multi-GB entry is never materialized in memory. Preferred over `save`
|
|
24
|
+
* when present. */
|
|
25
|
+
saveEntry?: ArchiveEntrySaver;
|
|
21
26
|
};
|
|
22
27
|
export interface ArchiveMountOptions extends MountOptions {
|
|
23
28
|
limits?: ResourceLimits;
|
|
29
|
+
createObjectUrl?(blob: Blob): string;
|
|
30
|
+
revokeObjectUrl?(url: string): void;
|
|
31
|
+
}
|
|
32
|
+
/** Lazy streaming input: the adapter's path-based decoder opens the archive
|
|
33
|
+
* without core ever holding the full file bytes (opt-in, opposite the buffered
|
|
34
|
+
* {@link ViewerInput} path used by vscode/chrome/web). */
|
|
35
|
+
export interface ArchiveStreamingSource extends ArchiveStreamDecoder {
|
|
36
|
+
fileName: string;
|
|
37
|
+
totalSize?: number;
|
|
38
|
+
lastModified?: number;
|
|
24
39
|
}
|
|
25
40
|
export declare function mountArchiveViewer(input: ViewerInput, container: HTMLElement, ctx: ArchiveViewerContext, deps: ArchiveViewerDeps, options?: ArchiveMountOptions): Promise<ViewerHandle>;
|
|
41
|
+
export declare function mountArchiveViewer(source: ArchiveStreamingSource, container: HTMLElement, ctx: ArchiveViewerContext, options?: ArchiveMountOptions): Promise<ViewerHandle>;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { ARCHIVE_DEFAULT_LIMITS, ArchiveError, parseArchive } from '../../parsers/archive/index.js';
|
|
1
|
+
import { ARCHIVE_DEFAULT_LIMITS, ArchiveError, openArchiveStream, parseArchive } from '../../parsers/archive/index.js';
|
|
2
|
+
import { audioMimeType } from '../../parsers/audio/index.js';
|
|
3
|
+
import { videoMimeType } from '../../parsers/video/index.js';
|
|
4
|
+
import { detectImageMime } from '../image/decode.js';
|
|
2
5
|
import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
|
|
3
6
|
import { createArchiveController } from './controller.js';
|
|
4
7
|
import { tryDecodeArchiveEntryPreview } from './archive-preview-decoder.js';
|
|
@@ -8,6 +11,17 @@ export * from '../../parsers/archive/index.js';
|
|
|
8
11
|
export { createArchiveController } from './controller.js';
|
|
9
12
|
export { archiveViewerCss } from './styles.js';
|
|
10
13
|
export const ARCHIVE_VIEWER_META = { id: 'archive', displayNameKey: 'archive.title', extensions: ['zip', 'jar', 'apk', 'tar', 'tgz', 'tar.gz', 'tbz', 'tbz2', 'tar.bz2', 'txz', 'tar.xz', 'gz', 'bz2', 'xz', '7z', 'rar', 'dmg'], priority: 5, requiredServices: [], optionalServices: ['save'], inputOwnership: 'consumes' };
|
|
14
|
+
/** Ceiling for extracting an audio entry into memory for inline playback.
|
|
15
|
+
* Larger than the text/hex preview budget, but bounded to avoid huge allocations. */
|
|
16
|
+
const ARCHIVE_AUDIO_PREVIEW_BYTES = 64 * 1024 * 1024;
|
|
17
|
+
/** Ceiling for extracting an image entry into memory for inline preview. */
|
|
18
|
+
const ARCHIVE_IMAGE_PREVIEW_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
/** Ceiling for extracting a video entry into memory for inline playback. The
|
|
20
|
+
* whole entry is buffered into a blob URL, so this is the largest of the caps. */
|
|
21
|
+
const ARCHIVE_VIDEO_PREVIEW_BYTES = 128 * 1024 * 1024;
|
|
22
|
+
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']);
|
|
23
|
+
const hasImageExtension = (path) => IMAGE_EXTENSIONS.has(path.toLowerCase().split('.').pop() ?? '');
|
|
24
|
+
const blobPart = (data) => (data.buffer instanceof ArrayBuffer ? data : new Uint8Array(data));
|
|
11
25
|
const element = (tag, className, text) => {
|
|
12
26
|
const node = document.createElement(tag);
|
|
13
27
|
if (className)
|
|
@@ -39,10 +53,26 @@ const archiveFormat = (name) => {
|
|
|
39
53
|
const lower = name.toLocaleLowerCase();
|
|
40
54
|
return ['tar.gz', 'tar.bz2', 'tar.xz'].find(ext => lower.endsWith(`.${ext}`))?.toUpperCase() ?? (lower.split('.').pop()?.toUpperCase() || 'ARCHIVE');
|
|
41
55
|
};
|
|
42
|
-
export async function mountArchiveViewer(
|
|
56
|
+
export async function mountArchiveViewer(inputOrSource, container, ctx, depsOrOptions, maybeOptions) {
|
|
57
|
+
const streaming = !('data' in inputOrSource);
|
|
58
|
+
const options = (streaming ? depsOrOptions : maybeOptions) ?? {};
|
|
43
59
|
if (options.signal?.aborted)
|
|
44
60
|
throw new MountAbortedError();
|
|
45
|
-
|
|
61
|
+
let fileName;
|
|
62
|
+
let totalBytes;
|
|
63
|
+
let parsed;
|
|
64
|
+
if (streaming) {
|
|
65
|
+
const source = inputOrSource;
|
|
66
|
+
fileName = source.fileName;
|
|
67
|
+
totalBytes = source.totalSize;
|
|
68
|
+
parsed = await openArchiveStream(source, { fileName, ...(source.totalSize !== undefined ? { totalSize: source.totalSize } : {}), ...(options.signal ? { signal: options.signal } : {}), ...(options.limits ? { limits: options.limits } : {}) });
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const input = inputOrSource;
|
|
72
|
+
fileName = input.fileName;
|
|
73
|
+
totalBytes = input.data.byteLength;
|
|
74
|
+
parsed = await parseArchive(input.data, depsOrOptions, { fileName, ...(options.signal ? { signal: options.signal } : {}), ...(options.limits ? { limits: options.limits } : {}) });
|
|
75
|
+
}
|
|
46
76
|
if (parsed.outcome.result.status === 'failed')
|
|
47
77
|
throw new Error(parsed.outcome.result.failure.messageKey);
|
|
48
78
|
if (options.signal?.aborted) {
|
|
@@ -64,9 +94,9 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
|
|
|
64
94
|
const wrap = element('section', `${VIEWER_ROOT_CLASS} omni-viewer--archive`);
|
|
65
95
|
const hero = element('header', 'omni-archive__hero');
|
|
66
96
|
const heading = element('div');
|
|
67
|
-
heading.append(element('div', 'omni-archive__eyebrow', t('archive.preview')), element('h1', '',
|
|
97
|
+
heading.append(element('div', 'omni-archive__eyebrow', t('archive.preview')), element('h1', '', fileName), element('p', 'omni-archive__subtitle', t('archive.summary')));
|
|
68
98
|
const pills = element('div', 'omni-archive__pills');
|
|
69
|
-
pills.append(element('span', 'omni-archive__pill', archiveFormat(
|
|
99
|
+
pills.append(element('span', 'omni-archive__pill', archiveFormat(fileName)), element('span', 'omni-archive__pill', t('archive.entries', { count: entries.length })), element('span', 'omni-archive__pill', formatSize(totalBytes)));
|
|
70
100
|
hero.append(heading, pills);
|
|
71
101
|
const controls = element('section', 'omni-archive__controls');
|
|
72
102
|
const label = element('label', 'omni-archive__search-label', t('archive.filter'));
|
|
@@ -107,11 +137,13 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
|
|
|
107
137
|
const previewMeta = element('p', 'omni-archive__preview-meta', t('archive.selectHint'));
|
|
108
138
|
const preview = element('pre', 'omni-archive__preview', t('archive.noSelection'));
|
|
109
139
|
preview.setAttribute('aria-live', 'polite');
|
|
140
|
+
const previewMedia = element('div', 'omni-archive__preview-media');
|
|
141
|
+
previewMedia.style.display = 'none';
|
|
110
142
|
const saveButton = element('button', 'omni-archive__save', t('archive.saveEntry'));
|
|
111
143
|
saveButton.type = 'button';
|
|
112
|
-
saveButton.hidden = !ctx.save;
|
|
144
|
+
saveButton.hidden = !ctx.save && !ctx.saveEntry;
|
|
113
145
|
saveButton.disabled = true;
|
|
114
|
-
previewPanel.append(previewHeader, previewMeta, preview, saveButton);
|
|
146
|
+
previewPanel.append(previewHeader, previewMeta, preview, previewMedia, saveButton);
|
|
115
147
|
workspace.append(tableWrap, previewPanel);
|
|
116
148
|
wrap.append(hero, controls, stats, workspace);
|
|
117
149
|
root.append(wrap);
|
|
@@ -119,7 +151,52 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
|
|
|
119
151
|
let extraction;
|
|
120
152
|
let saveExtraction;
|
|
121
153
|
let selectedEntry;
|
|
122
|
-
|
|
154
|
+
let previewUrl;
|
|
155
|
+
const createUrl = options.createObjectUrl ?? URL.createObjectURL.bind(URL);
|
|
156
|
+
const revokeUrl = options.revokeObjectUrl ?? URL.revokeObjectURL.bind(URL);
|
|
157
|
+
// Inline display beats author CSS (.omni-archive__preview-media sets display:flex,
|
|
158
|
+
// which would otherwise override the [hidden] attribute), so the text preview and
|
|
159
|
+
// the media preview stay reliably mutually exclusive.
|
|
160
|
+
const clearPreviewMedia = () => { if (previewUrl) {
|
|
161
|
+
revokeUrl(previewUrl);
|
|
162
|
+
previewUrl = undefined;
|
|
163
|
+
} previewMedia.replaceChildren(); previewMedia.style.display = 'none'; preview.style.display = ''; };
|
|
164
|
+
const setPreview = (entry, status, meta, content) => { clearPreviewMedia(); previewTitle.textContent = entry?.path ?? t('archive.chooseFile'); previewStatus.textContent = status; previewMeta.textContent = meta; preview.textContent = content; };
|
|
165
|
+
const MEDIA_META = { audio: 'archive.audioReady', image: 'archive.imageReady', video: 'archive.videoReady' };
|
|
166
|
+
const showMediaPreview = (entry, data, kind, mimeType) => {
|
|
167
|
+
clearPreviewMedia();
|
|
168
|
+
let objectUrl;
|
|
169
|
+
try {
|
|
170
|
+
objectUrl = createUrl(new Blob([blobPart(data)], { type: mimeType }));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
setPreview(entry, t('archive.previewUnavailable'), t('archive.mediaUnsupported'), t('archive.previewUnavailable'));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
previewUrl = objectUrl;
|
|
177
|
+
let node;
|
|
178
|
+
if (kind === 'image') {
|
|
179
|
+
const img = document.createElement('img');
|
|
180
|
+
img.className = 'omni-archive__image';
|
|
181
|
+
img.alt = entry.path;
|
|
182
|
+
img.src = objectUrl;
|
|
183
|
+
node = img;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const media = document.createElement(kind);
|
|
187
|
+
media.controls = true;
|
|
188
|
+
media.preload = 'metadata';
|
|
189
|
+
media.className = `omni-archive__${kind}`;
|
|
190
|
+
media.src = objectUrl;
|
|
191
|
+
node = media;
|
|
192
|
+
}
|
|
193
|
+
previewMedia.replaceChildren(node);
|
|
194
|
+
previewMedia.style.display = 'flex';
|
|
195
|
+
preview.style.display = 'none';
|
|
196
|
+
previewTitle.textContent = entry.path;
|
|
197
|
+
previewStatus.textContent = t('archive.ready');
|
|
198
|
+
previewMeta.textContent = t(MEDIA_META[kind]);
|
|
199
|
+
};
|
|
123
200
|
const select = async (entry) => {
|
|
124
201
|
extraction?.abort();
|
|
125
202
|
extraction = undefined;
|
|
@@ -138,7 +215,10 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
|
|
|
138
215
|
extraction = new AbortController();
|
|
139
216
|
const request = extraction;
|
|
140
217
|
setPreview(entry, t('archive.loading'), t('archive.loadingHint'), t('archive.loadingContent'));
|
|
141
|
-
const
|
|
218
|
+
const audioMime = audioMimeType(entry.path);
|
|
219
|
+
const videoMime = !audioMime ? videoMimeType(entry.path) : undefined;
|
|
220
|
+
const imageCandidate = !audioMime && !videoMime && hasImageExtension(entry.path);
|
|
221
|
+
const maxPreview = audioMime ? ARCHIVE_AUDIO_PREVIEW_BYTES : videoMime ? ARCHIVE_VIDEO_PREVIEW_BYTES : imageCandidate ? ARCHIVE_IMAGE_PREVIEW_BYTES : ARCHIVE_DEFAULT_LIMITS.maxPreviewBytes;
|
|
142
222
|
if (entry.uncompressedSize !== undefined && entry.uncompressedSize > maxPreview) {
|
|
143
223
|
setPreview(entry, t('archive.previewUnavailable'), t('diag.archive.limit-exceeded'), t('archive.previewUnavailable'));
|
|
144
224
|
return;
|
|
@@ -147,6 +227,21 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
|
|
|
147
227
|
const data = await handle.extract(entry.entryId, { signal: request.signal, maxBytes: maxPreview });
|
|
148
228
|
if (local !== ticket)
|
|
149
229
|
return;
|
|
230
|
+
if (audioMime) {
|
|
231
|
+
showMediaPreview(entry, data, 'audio', audioMime);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (videoMime) {
|
|
235
|
+
showMediaPreview(entry, data, 'video', videoMime);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (imageCandidate) {
|
|
239
|
+
const imageMime = detectImageMime(data, entry.path);
|
|
240
|
+
if (imageMime) {
|
|
241
|
+
showMediaPreview(entry, data, 'image', imageMime);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
150
245
|
const decoded = tryDecodeArchiveEntryPreview(entry.path, data);
|
|
151
246
|
const text = isText(data);
|
|
152
247
|
setPreview(entry, t('archive.ready'), t(decoded ? 'archive.androidBinaryXmlReady' : text ? 'archive.textReady' : 'archive.binaryReady'), decoded?.content ?? (text ? new TextDecoder().decode(data) : hexPreview(data)));
|
|
@@ -158,24 +253,32 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
|
|
|
158
253
|
};
|
|
159
254
|
saveButton.addEventListener('click', async () => {
|
|
160
255
|
const entry = selectedEntry;
|
|
161
|
-
|
|
162
|
-
if (!entry || entry.isDirectory || !save || !handle)
|
|
256
|
+
if (!entry || entry.isDirectory || !handle || (!ctx.save && !ctx.saveEntry))
|
|
163
257
|
return;
|
|
164
258
|
saveExtraction?.abort();
|
|
165
259
|
saveExtraction = new AbortController();
|
|
166
260
|
const request = saveExtraction;
|
|
167
261
|
saveButton.disabled = true;
|
|
168
262
|
saveButton.textContent = t('archive.savingEntry');
|
|
263
|
+
const suggestedName = entry.path.split('/').filter(Boolean).pop() ?? 'archive-entry';
|
|
169
264
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
265
|
+
// Streaming save (adapter pipes the entry to disk) is preferred: a
|
|
266
|
+
// multi-GB entry never lands in memory. Buffered save is the fallback.
|
|
267
|
+
if (ctx.saveEntry) {
|
|
268
|
+
const savedName = await ctx.saveEntry.saveEntry(entry, { signal: request.signal });
|
|
269
|
+
if (!request.signal.aborted && savedName)
|
|
270
|
+
previewMeta.textContent = t('common.saved', { name: savedName });
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
const configuredLimit = options.limits?.maxDecompressedBytes ?? ARCHIVE_DEFAULT_LIMITS.maxDecompressedBytes;
|
|
274
|
+
const maxBytes = Math.max(1, Math.min(entry.uncompressedSize ?? configuredLimit, configuredLimit));
|
|
275
|
+
const data = await handle.extract(entry.entryId, { signal: request.signal, maxBytes });
|
|
276
|
+
if (request.signal.aborted)
|
|
277
|
+
return;
|
|
278
|
+
await ctx.save.saveFile(suggestedName, data, entry.mimeType ?? 'application/octet-stream');
|
|
279
|
+
if (!request.signal.aborted)
|
|
280
|
+
previewMeta.textContent = t('common.saved', { name: suggestedName });
|
|
281
|
+
}
|
|
179
282
|
}
|
|
180
283
|
catch {
|
|
181
284
|
if (!request.signal.aborted)
|
|
@@ -226,5 +329,5 @@ export async function mountArchiveViewer(input, container, ctx, deps, options =
|
|
|
226
329
|
search.addEventListener('input', () => { tableWrap.scrollTop = 0; controller.dispatch({ type: 'set-search', query: search.value }); });
|
|
227
330
|
const unsubscribe = controller.subscribe(render);
|
|
228
331
|
render();
|
|
229
|
-
return { dispose() { ticket++; extraction?.abort(); saveExtraction?.abort(); unsubscribe(); wrap.remove(); void handle?.close(); handle = undefined; } };
|
|
332
|
+
return { dispose() { ticket++; extraction?.abort(); saveExtraction?.abort(); clearPreviewMedia(); unsubscribe(); wrap.remove(); void handle?.close(); handle = undefined; } };
|
|
230
333
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const archiveViewerCss = "\n.omni-viewer--archive{all:initial}.omni-viewer--archive :where(header,section,aside,div,h1,h2,p,span,label,input,table,thead,tbody,tr,th,td,pre){all:revert}\n:host,.omni-viewer--archive{display:block;min-height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:radial-gradient(circle at top left,color-mix(in srgb,var(--omni-accent,#d97706) 14%,transparent),transparent 28%),var(--omni-bg,#1e1e1e);font:13px var(--omni-font,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif)}\n.omni-viewer--archive *{box-sizing:border-box}.omni-viewer--archive{padding:28px 20px 40px}.omni-archive__hero,.omni-archive__controls,.omni-archive__stats,.omni-archive__table-wrap,.omni-archive__preview-panel{background:var(--omni-bg-secondary,#252526);border:1px solid var(--omni-border,#3c3c3c);border-radius:18px;box-shadow:0 20px 40px rgba(0,0,0,.12)}\n.omni-archive__hero{display:flex;justify-content:space-between;gap:16px;padding:24px;align-items:flex-start}.omni-archive__eyebrow{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--omni-accent,#d97706);font-weight:700}.omni-archive__hero h1{margin:8px 0 6px;font-size:clamp(26px,4vw,40px);line-height:1.1}.omni-archive__subtitle,.omni-archive__preview-meta{color:var(--omni-muted-fg,#9d9d9d);line-height:1.5}.omni-archive__pills{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.omni-archive__pill{padding:10px 14px;border-radius:999px;background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent);border:1px solid color-mix(in srgb,var(--omni-accent,#d97706) 35%,transparent);font-weight:600;white-space:nowrap}\n.omni-archive__controls,.omni-archive__stats,.omni-archive__workspace{margin-top:16px}.omni-archive__controls{padding:16px}.omni-archive__search-label{display:block;font-weight:600}.omni-archive__search{display:block;width:100%;margin-top:8px;padding:12px 14px;border-radius:12px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-input-bg,#3c3c3c);color:var(--omni-fg,#d4d4d4);font:inherit}.omni-archive__stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;padding:16px}.omni-archive__stat{padding:14px;border-radius:14px;background:color-mix(in srgb,var(--omni-bg-secondary,#252526) 76%,#000);border:1px solid var(--omni-border,#3c3c3c)}.omni-archive__stat-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__stat-value{font-size:24px;font-weight:700}\n.omni-archive__workspace{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(320px,.95fr);gap:16px;align-items:start}.omni-archive__table-wrap{overflow:auto;min-width:0;max-height:70vh}.omni-archive__table{width:100%;table-layout:fixed;border-collapse:collapse}.omni-archive__table th,.omni-archive__table td{padding:12px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);text-align:left;vertical-align:top;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-archive__table th:first-child{width:42%}.omni-archive__table th{position:sticky;top:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);background:var(--omni-bg-secondary,#252526);z-index:1}.omni-archive__path{font-family:var(--omni-mono-font,\"SFMono-Regular\",Consolas,monospace)}.omni-archive__entry{height:43px;cursor:pointer;transition:background-color 120ms ease}.omni-archive__entry:hover{background:color-mix(in srgb,var(--omni-accent,#d97706) 8%,transparent)}.omni-archive__entry:focus-visible{outline:2px solid var(--omni-accent,#d97706);outline-offset:-2px}.omni-archive__entry.is-selected{background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent)}.omni-archive__empty{padding:26px 18px;text-align:center;color:var(--omni-muted-fg,#9d9d9d)}\n.omni-archive__preview-panel{padding:18px;min-height:420px;display:flex;flex-direction:column;gap:14px}.omni-archive__preview-header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.omni-archive__preview-kicker{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__preview-header h2{margin:0;font-size:18px;line-height:1.35;word-break:break-word}.omni-archive__preview-badge{border-radius:999px;padding:8px 12px;font-size:12px;font-weight:700;background:var(--omni-badge-bg,#3a3d41);white-space:nowrap}.omni-archive__preview-meta{margin:0}.omni-archive__preview{margin:0;flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;color:var(--omni-fg,#d4d4d4);font:12px/1.55 var(--omni-mono-font,\"SFMono-Regular\",Consolas,monospace)}\n.omni-archive__save{align-self:flex-start;padding:8px 12px;border:1px solid var(--omni-border,#3c3c3c);border-radius:6px;background:var(--omni-button-bg,#3a3d41);color:var(--omni-fg,#d4d4d4);font:inherit;cursor:pointer}.omni-archive__save:hover:not(:disabled){background:var(--omni-button-hover-bg,#45494e)}.omni-archive__save:disabled{opacity:.55;cursor:not-allowed}.omni-archive__spacer td{padding:0!important;border:0!important}\n@media(max-width:720px){.omni-viewer--archive{padding:16px 10px 24px}.omni-archive__hero{flex-direction:column}.omni-archive__pills{justify-content:flex-start}.omni-archive__table th:nth-child(n+3),.omni-archive__table td:nth-child(n+3){display:none}.omni-archive__workspace{grid-template-columns:1fr}.omni-archive__preview-panel{min-height:280px}}\n";
|
|
1
|
+
export declare const archiveViewerCss = "\n.omni-viewer--archive{all:initial}.omni-viewer--archive :where(header,section,aside,div,h1,h2,p,span,label,input,table,thead,tbody,tr,th,td,pre){all:revert}\n:host,.omni-viewer--archive{display:block;min-height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:radial-gradient(circle at top left,color-mix(in srgb,var(--omni-accent,#d97706) 14%,transparent),transparent 28%),var(--omni-bg,#1e1e1e);font:13px var(--omni-font,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif)}\n.omni-viewer--archive *{box-sizing:border-box}.omni-viewer--archive{padding:28px 20px 40px}.omni-archive__hero,.omni-archive__controls,.omni-archive__stats,.omni-archive__table-wrap,.omni-archive__preview-panel{background:var(--omni-bg-secondary,#252526);border:1px solid var(--omni-border,#3c3c3c);border-radius:18px;box-shadow:0 20px 40px rgba(0,0,0,.12)}\n.omni-archive__hero{display:flex;justify-content:space-between;gap:16px;padding:24px;align-items:flex-start}.omni-archive__eyebrow{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--omni-accent,#d97706);font-weight:700}.omni-archive__hero h1{margin:8px 0 6px;font-size:clamp(26px,4vw,40px);line-height:1.1}.omni-archive__subtitle,.omni-archive__preview-meta{color:var(--omni-muted-fg,#9d9d9d);line-height:1.5}.omni-archive__pills{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.omni-archive__pill{padding:10px 14px;border-radius:999px;background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent);border:1px solid color-mix(in srgb,var(--omni-accent,#d97706) 35%,transparent);font-weight:600;white-space:nowrap}\n.omni-archive__controls,.omni-archive__stats,.omni-archive__workspace{margin-top:16px}.omni-archive__controls{padding:16px}.omni-archive__search-label{display:block;font-weight:600}.omni-archive__search{display:block;width:100%;margin-top:8px;padding:12px 14px;border-radius:12px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-input-bg,#3c3c3c);color:var(--omni-fg,#d4d4d4);font:inherit}.omni-archive__stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;padding:16px}.omni-archive__stat{padding:14px;border-radius:14px;background:color-mix(in srgb,var(--omni-bg-secondary,#252526) 76%,#000);border:1px solid var(--omni-border,#3c3c3c)}.omni-archive__stat-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__stat-value{font-size:24px;font-weight:700}\n.omni-archive__workspace{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(320px,.95fr);gap:16px;align-items:start}.omni-archive__table-wrap{overflow:auto;min-width:0;max-height:70vh}.omni-archive__table{width:100%;table-layout:fixed;border-collapse:collapse}.omni-archive__table th,.omni-archive__table td{padding:12px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);text-align:left;vertical-align:top;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-archive__table th:first-child{width:42%}.omni-archive__table th{position:sticky;top:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);background:var(--omni-bg-secondary,#252526);z-index:1}.omni-archive__path{font-family:var(--omni-mono-font,\"SFMono-Regular\",Consolas,monospace)}.omni-archive__entry{height:43px;cursor:pointer;transition:background-color 120ms ease}.omni-archive__entry:hover{background:color-mix(in srgb,var(--omni-accent,#d97706) 8%,transparent)}.omni-archive__entry:focus-visible{outline:2px solid var(--omni-accent,#d97706);outline-offset:-2px}.omni-archive__entry.is-selected{background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent)}.omni-archive__empty{padding:26px 18px;text-align:center;color:var(--omni-muted-fg,#9d9d9d)}\n.omni-archive__preview-panel{padding:18px;min-height:420px;display:flex;flex-direction:column;gap:14px}.omni-archive__preview-header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.omni-archive__preview-kicker{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__preview-header h2{margin:0;font-size:18px;line-height:1.35;word-break:break-word}.omni-archive__preview-badge{border-radius:999px;padding:8px 12px;font-size:12px;font-weight:700;background:var(--omni-badge-bg,#3a3d41);white-space:nowrap}.omni-archive__preview-meta{margin:0}.omni-archive__preview{margin:0;flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;color:var(--omni-fg,#d4d4d4);font:12px/1.55 var(--omni-mono-font,\"SFMono-Regular\",Consolas,monospace)}\n.omni-archive__save{align-self:flex-start;padding:8px 12px;border:1px solid var(--omni-border,#3c3c3c);border-radius:6px;background:var(--omni-button-bg,#3a3d41);color:var(--omni-fg,#d4d4d4);font:inherit;cursor:pointer}.omni-archive__save:hover:not(:disabled){background:var(--omni-button-hover-bg,#45494e)}.omni-archive__save:disabled{opacity:.55;cursor:not-allowed}.omni-archive__spacer td{padding:0!important;border:0!important}\n.omni-archive__preview-media{flex:1;display:flex;align-items:center;justify-content:center;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;overflow:auto}.omni-archive__audio{width:100%}.omni-archive__video{max-width:100%;max-height:100%}.omni-archive__image{max-width:100%;max-height:100%;object-fit:contain}\n@media(max-width:720px){.omni-viewer--archive{padding:16px 10px 24px}.omni-archive__hero{flex-direction:column}.omni-archive__pills{justify-content:flex-start}.omni-archive__table th:nth-child(n+3),.omni-archive__table td:nth-child(n+3){display:none}.omni-archive__workspace{grid-template-columns:1fr}.omni-archive__preview-panel{min-height:280px}}\n";
|
|
@@ -7,5 +7,6 @@ export const archiveViewerCss = `
|
|
|
7
7
|
.omni-archive__workspace{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(320px,.95fr);gap:16px;align-items:start}.omni-archive__table-wrap{overflow:auto;min-width:0;max-height:70vh}.omni-archive__table{width:100%;table-layout:fixed;border-collapse:collapse}.omni-archive__table th,.omni-archive__table td{padding:12px 14px;border-bottom:1px solid var(--omni-border,#3c3c3c);text-align:left;vertical-align:top;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-archive__table th:first-child{width:42%}.omni-archive__table th{position:sticky;top:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--omni-muted-fg,#9d9d9d);background:var(--omni-bg-secondary,#252526);z-index:1}.omni-archive__path{font-family:var(--omni-mono-font,"SFMono-Regular",Consolas,monospace)}.omni-archive__entry{height:43px;cursor:pointer;transition:background-color 120ms ease}.omni-archive__entry:hover{background:color-mix(in srgb,var(--omni-accent,#d97706) 8%,transparent)}.omni-archive__entry:focus-visible{outline:2px solid var(--omni-accent,#d97706);outline-offset:-2px}.omni-archive__entry.is-selected{background:color-mix(in srgb,var(--omni-accent,#d97706) 16%,transparent)}.omni-archive__empty{padding:26px 18px;text-align:center;color:var(--omni-muted-fg,#9d9d9d)}
|
|
8
8
|
.omni-archive__preview-panel{padding:18px;min-height:420px;display:flex;flex-direction:column;gap:14px}.omni-archive__preview-header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.omni-archive__preview-kicker{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--omni-muted-fg,#9d9d9d);margin-bottom:6px}.omni-archive__preview-header h2{margin:0;font-size:18px;line-height:1.35;word-break:break-word}.omni-archive__preview-badge{border-radius:999px;padding:8px 12px;font-size:12px;font-weight:700;background:var(--omni-badge-bg,#3a3d41);white-space:nowrap}.omni-archive__preview-meta{margin:0}.omni-archive__preview{margin:0;flex:1;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;color:var(--omni-fg,#d4d4d4);font:12px/1.55 var(--omni-mono-font,"SFMono-Regular",Consolas,monospace)}
|
|
9
9
|
.omni-archive__save{align-self:flex-start;padding:8px 12px;border:1px solid var(--omni-border,#3c3c3c);border-radius:6px;background:var(--omni-button-bg,#3a3d41);color:var(--omni-fg,#d4d4d4);font:inherit;cursor:pointer}.omni-archive__save:hover:not(:disabled){background:var(--omni-button-hover-bg,#45494e)}.omni-archive__save:disabled{opacity:.55;cursor:not-allowed}.omni-archive__spacer td{padding:0!important;border:0!important}
|
|
10
|
+
.omni-archive__preview-media{flex:1;display:flex;align-items:center;justify-content:center;border-radius:14px;border:1px solid var(--omni-border,#3c3c3c);background:var(--omni-code-bg,#181818);padding:14px;overflow:auto}.omni-archive__audio{width:100%}.omni-archive__video{max-width:100%;max-height:100%}.omni-archive__image{max-width:100%;max-height:100%;object-fit:contain}
|
|
10
11
|
@media(max-width:720px){.omni-viewer--archive{padding:16px 10px 24px}.omni-archive__hero{flex-direction:column}.omni-archive__pills{justify-content:flex-start}.omni-archive__table th:nth-child(n+3),.omni-archive__table td:nth-child(n+3){display:none}.omni-archive__workspace{grid-template-columns:1fr}.omni-archive__preview-panel{min-height:280px}}
|
|
11
12
|
`;
|
|
@@ -61,6 +61,14 @@ export const audioViewerCss = mediaViewerCss + `
|
|
|
61
61
|
.omni-audio__spectrogram { min-height: 0; }
|
|
62
62
|
.omni-audio__spectrogram--active { min-height: 200px; }
|
|
63
63
|
|
|
64
|
+
/* Keep the WaveSurfer playhead (cursor + played-region overlay) pinned to the
|
|
65
|
+
waveform strip. Some wavesurfer spectrogram builds render the spectrogram
|
|
66
|
+
inside the main wrapper instead of our dedicated container; without this the
|
|
67
|
+
full-height playhead sweeps down across the spectrogram ("progress bar"
|
|
68
|
+
running along the bottom) rather than staying with the waveform at the top. */
|
|
69
|
+
.omni-audio__waveform ::part(cursor),
|
|
70
|
+
.omni-audio__waveform ::part(progress) { height: 128px !important; }
|
|
71
|
+
|
|
64
72
|
.omni-audio__status { color: var(--omni-muted, #9d9d9d); font-size: 12px; min-height: 16px; }
|
|
65
73
|
|
|
66
74
|
.omni-audio__loading { display: flex; justify-content: center; align-items: center; min-height: 120px; color: var(--omni-muted, #9d9d9d); }
|
|
@@ -2,7 +2,7 @@ import { type PdfAnnotation, type PdfViewState } from './controller.js';
|
|
|
2
2
|
/** Type-only reference — erased at compile time. */
|
|
3
3
|
export type PdfLibModule = typeof import('pdf-lib');
|
|
4
4
|
export declare const SIDECAR_LAYER_NAME = "omni-viewer-layer.json";
|
|
5
|
-
export declare const
|
|
5
|
+
export declare const SIDECAR_BASE_NAME = "omni-viewer-base.pdf";
|
|
6
6
|
/** Result of parsing a sidecar's layer JSON. */
|
|
7
7
|
export interface ParsedLayer {
|
|
8
8
|
pageOrder: number[];
|
|
@@ -14,9 +14,11 @@ export declare function buildEditedPdf(pdfLib: PdfLibModule, source: Uint8Array,
|
|
|
14
14
|
/**
|
|
15
15
|
* Like {@link buildEditedPdf}, but also embeds the hybrid sidecar so the
|
|
16
16
|
* omni viewer can rehydrate a removable overlay layer on reopen:
|
|
17
|
-
* -
|
|
18
|
-
* - the layer JSON `{ version, pageOrder, annotations }
|
|
19
|
-
*
|
|
17
|
+
* - a re-editable base (kept pages only, signatures flattened in), and
|
|
18
|
+
* - the layer JSON `{ version, pageOrder, annotations }` for text/markup,
|
|
19
|
+
* with page references remapped onto the kept-pages base.
|
|
20
|
+
* Deleted pages and signatures are unrecoverable; external readers only ever
|
|
21
|
+
* see the flattened pages.
|
|
20
22
|
*/
|
|
21
23
|
export declare function buildSavedPdf(pdfLib: PdfLibModule, pristine: Uint8Array, state: PdfViewState): Promise<Uint8Array>;
|
|
22
24
|
/** Parse and validate a sidecar layer JSON string; null if unusable. */
|
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
// imports). Callers inject the loaded module; `viewers/pdf/self-loading`
|
|
6
6
|
// provides the dynamic-import loader for platforms whose bundler resolves it.
|
|
7
7
|
import { mergeHighlightRects } from './controller.js';
|
|
8
|
-
// Hybrid sidecar: saved PDFs keep flattened (portable) pages
|
|
9
|
-
//
|
|
10
|
-
// removable overlay layer on reopen.
|
|
8
|
+
// Hybrid sidecar: saved PDFs keep flattened (portable) pages for external
|
|
9
|
+
// readers while embedding a re-editable base + a layer JSON, so the omni
|
|
10
|
+
// viewer can restore a removable overlay layer on reopen. The embedded base
|
|
11
|
+
// holds only the kept pages (deleted pages are gone for good) with signatures
|
|
12
|
+
// permanently flattened in; text/markup stay out so they remain editable.
|
|
13
|
+
// External readers only ever see the flat pages.
|
|
11
14
|
export const SIDECAR_LAYER_NAME = 'omni-viewer-layer.json';
|
|
12
|
-
export const
|
|
15
|
+
export const SIDECAR_BASE_NAME = 'omni-viewer-base.pdf';
|
|
13
16
|
/** Bumped when the sidecar shape changes; older/newer versions are ignored. */
|
|
14
|
-
const SIDECAR_VERSION =
|
|
17
|
+
const SIDECAR_VERSION = 2;
|
|
15
18
|
/** Defensive ceilings — the sidecar is untrusted input on reopen. */
|
|
16
19
|
const MAX_ANNOTATIONS = 2000;
|
|
17
20
|
const MAX_TEXT_LENGTH = 10_000;
|
|
@@ -104,16 +107,39 @@ async function stamp(pdfLib, page, annotation) {
|
|
|
104
107
|
height: annotation.height
|
|
105
108
|
});
|
|
106
109
|
}
|
|
107
|
-
/**
|
|
108
|
-
async function
|
|
110
|
+
/** Copy the kept pages (in display order) from `source` into a fresh doc. */
|
|
111
|
+
async function copyKeptPages(pdfLib, source, pageOrder) {
|
|
109
112
|
const original = await pdfLib.PDFDocument.load(source);
|
|
110
113
|
const output = await pdfLib.PDFDocument.create();
|
|
111
|
-
const indices =
|
|
114
|
+
const indices = pageOrder
|
|
112
115
|
.map((page) => page - 1)
|
|
113
116
|
.filter((page) => page >= 0 && page < original.getPageCount());
|
|
114
117
|
const copied = await output.copyPages(original, indices);
|
|
115
118
|
copied.forEach((page) => output.addPage(page));
|
|
119
|
+
return output;
|
|
120
|
+
}
|
|
121
|
+
/** Reorder/delete pages and stamp the overlays into a fresh (unsaved) doc. */
|
|
122
|
+
async function flattenInto(pdfLib, source, state) {
|
|
123
|
+
const output = await copyKeptPages(pdfLib, source, state.pageOrder);
|
|
124
|
+
for (const annotation of state.annotations) {
|
|
125
|
+
const outputIndex = state.pageOrder.indexOf(annotation.page);
|
|
126
|
+
const page = output.getPages()[outputIndex];
|
|
127
|
+
if (page)
|
|
128
|
+
await stamp(pdfLib, page, annotation);
|
|
129
|
+
}
|
|
130
|
+
return output;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The re-editable base embedded in a saved PDF: the kept pages (unflattened)
|
|
134
|
+
* with ONLY signatures permanently stamped in. Text/markup are deliberately
|
|
135
|
+
* left out so the omni viewer can restore them as a removable layer, while
|
|
136
|
+
* signatures — and any pages the user deleted — cannot be recovered.
|
|
137
|
+
*/
|
|
138
|
+
async function buildEditableBase(pdfLib, pristine, state) {
|
|
139
|
+
const output = await copyKeptPages(pdfLib, pristine, state.pageOrder);
|
|
116
140
|
for (const annotation of state.annotations) {
|
|
141
|
+
if (annotation.kind !== 'signature')
|
|
142
|
+
continue;
|
|
117
143
|
const outputIndex = state.pageOrder.indexOf(annotation.page);
|
|
118
144
|
const page = output.getPages()[outputIndex];
|
|
119
145
|
if (page)
|
|
@@ -130,21 +156,29 @@ export async function buildEditedPdf(pdfLib, source, state) {
|
|
|
130
156
|
/**
|
|
131
157
|
* Like {@link buildEditedPdf}, but also embeds the hybrid sidecar so the
|
|
132
158
|
* omni viewer can rehydrate a removable overlay layer on reopen:
|
|
133
|
-
* -
|
|
134
|
-
* - the layer JSON `{ version, pageOrder, annotations }
|
|
135
|
-
*
|
|
159
|
+
* - a re-editable base (kept pages only, signatures flattened in), and
|
|
160
|
+
* - the layer JSON `{ version, pageOrder, annotations }` for text/markup,
|
|
161
|
+
* with page references remapped onto the kept-pages base.
|
|
162
|
+
* Deleted pages and signatures are unrecoverable; external readers only ever
|
|
163
|
+
* see the flattened pages.
|
|
136
164
|
*/
|
|
137
165
|
export async function buildSavedPdf(pdfLib, pristine, state) {
|
|
138
166
|
const output = await flattenInto(pdfLib, pristine, state);
|
|
167
|
+
const base = await buildEditableBase(pdfLib, pristine, state);
|
|
168
|
+
// The base is already in display order, so its pages number 1..keptCount.
|
|
169
|
+
// Remap each overlay's page reference (an original page number) onto that.
|
|
170
|
+
const remap = new Map(state.pageOrder.map((page, index) => [page, index + 1]));
|
|
139
171
|
const layer = JSON.stringify({
|
|
140
172
|
version: SIDECAR_VERSION,
|
|
141
|
-
pageOrder:
|
|
173
|
+
pageOrder: base.getPages().map((_, index) => index + 1),
|
|
142
174
|
annotations: state.annotations
|
|
175
|
+
.filter((annotation) => annotation.kind !== 'signature')
|
|
176
|
+
.map((annotation) => ({ ...annotation, page: remap.get(annotation.page) ?? annotation.page }))
|
|
143
177
|
});
|
|
144
178
|
await output.attach(new TextEncoder().encode(layer), SIDECAR_LAYER_NAME, {
|
|
145
179
|
mimeType: 'application/json'
|
|
146
180
|
});
|
|
147
|
-
await output.attach(
|
|
181
|
+
await output.attach(await base.save(), SIDECAR_BASE_NAME, {
|
|
148
182
|
mimeType: 'application/pdf'
|
|
149
183
|
});
|
|
150
184
|
return output.save();
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// annotations use an inline input overlay instead.
|
|
12
12
|
import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
|
|
13
13
|
import { createPdfController, mergeHighlightRects, PDF_ZOOM_LEVELS } from './controller.js';
|
|
14
|
-
import { buildSavedPdf, mergePdfBytes, parseLayer, savedPdfName, SIDECAR_LAYER_NAME,
|
|
14
|
+
import { buildSavedPdf, mergePdfBytes, parseLayer, savedPdfName, SIDECAR_LAYER_NAME, SIDECAR_BASE_NAME } from './editing.js';
|
|
15
15
|
import { pdfViewerCss } from './styles.js';
|
|
16
16
|
export { createPdfController, PDF_ZOOM_LEVELS } from './controller.js';
|
|
17
17
|
export { pdfViewerCss } from './styles.js';
|
|
@@ -176,7 +176,7 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
|
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
178
|
/**
|
|
179
|
-
* Read the hybrid sidecar (
|
|
179
|
+
* Read the hybrid sidecar (re-editable base + layer JSON) from a document's
|
|
180
180
|
* attachments. Returns null unless BOTH parts are present and the layer
|
|
181
181
|
* parses — a lone layer over already-flattened pages would double up.
|
|
182
182
|
*/
|
|
@@ -185,19 +185,19 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
|
|
|
185
185
|
const attachments = await d.getAttachments?.();
|
|
186
186
|
if (!attachments)
|
|
187
187
|
return null;
|
|
188
|
-
let
|
|
188
|
+
let base;
|
|
189
189
|
let layerText;
|
|
190
190
|
for (const entry of Object.values(attachments)) {
|
|
191
|
-
if (entry.filename ===
|
|
192
|
-
|
|
191
|
+
if (entry.filename === SIDECAR_BASE_NAME)
|
|
192
|
+
base = entry.content;
|
|
193
193
|
else if (entry.filename === SIDECAR_LAYER_NAME) {
|
|
194
194
|
layerText = new TextDecoder().decode(entry.content);
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
|
-
if (!
|
|
197
|
+
if (!base || layerText === undefined)
|
|
198
198
|
return null;
|
|
199
199
|
const layer = parseLayer(layerText);
|
|
200
|
-
return layer ? {
|
|
200
|
+
return layer ? { base, layer } : null;
|
|
201
201
|
}
|
|
202
202
|
catch {
|
|
203
203
|
return null;
|
|
@@ -1374,12 +1374,13 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
|
|
|
1374
1374
|
}
|
|
1375
1375
|
throwIfAborted();
|
|
1376
1376
|
// Hybrid rehydration: if this file was saved by us, its visible pages are
|
|
1377
|
-
// flattened but it carries
|
|
1378
|
-
// from
|
|
1377
|
+
// flattened but it carries a re-editable base (kept pages, signatures baked
|
|
1378
|
+
// in) + layer JSON. Render/edit from that base and restore the text/markup
|
|
1379
|
+
// overlays as a removable layer.
|
|
1379
1380
|
const sidecar = await readSidecar(doc);
|
|
1380
1381
|
if (sidecar) {
|
|
1381
1382
|
await doc.destroy();
|
|
1382
|
-
sourceBytes = sidecar.
|
|
1383
|
+
sourceBytes = sidecar.base;
|
|
1383
1384
|
doc = await loadDocument(sourceBytes, password);
|
|
1384
1385
|
throwIfAborted();
|
|
1385
1386
|
adoptController(createPdfController(doc.numPages, sidecar.layer));
|