inicontent 1.0.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/.github/pull_request.yml +18 -0
- package/CONTRIBUTING.md +67 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/app/app.vue +106 -0
- package/app/assets/main.css +974 -0
- package/app/components/Api/EndpointCard.vue +169 -0
- package/app/components/Api/SchemaDocsTable.vue +208 -0
- package/app/components/Asset/Card.vue +1363 -0
- package/app/components/Asset/Grid.vue +295 -0
- package/app/components/Asset/Icon.vue +38 -0
- package/app/components/Asset/Preview.vue +486 -0
- package/app/components/Asset/Scanner.vue +700 -0
- package/app/components/Asset/Thumb.vue +708 -0
- package/app/components/Auth/Reset.vue +99 -0
- package/app/components/Auth/index.vue +346 -0
- package/app/components/ChatInterface.vue +2326 -0
- package/app/components/ChatPagePreview.vue +34 -0
- package/app/components/Column/Array.vue +29 -0
- package/app/components/Column/Asset.vue +21 -0
- package/app/components/Column/Boolean.vue +12 -0
- package/app/components/Column/Color.vue +9 -0
- package/app/components/Column/Date.vue +57 -0
- package/app/components/Column/Edit.vue +215 -0
- package/app/components/Column/Email.vue +9 -0
- package/app/components/Column/Html.vue +27 -0
- package/app/components/Column/Id.vue +20 -0
- package/app/components/Column/Locale.vue +32 -0
- package/app/components/Column/Object.vue +18 -0
- package/app/components/Column/Password.vue +7 -0
- package/app/components/Column/Role.vue +15 -0
- package/app/components/Column/S.vue +14 -0
- package/app/components/Column/Table/Single.vue +49 -0
- package/app/components/Column/Table/index.vue +42 -0
- package/app/components/Column/Tags.vue +31 -0
- package/app/components/Column/Text.vue +9 -0
- package/app/components/Column/Url.vue +9 -0
- package/app/components/Column/index.vue +41 -0
- package/app/components/Dashboard/Editor.vue +221 -0
- package/app/components/Dashboard/Grid.vue +270 -0
- package/app/components/Dashboard/View.vue +131 -0
- package/app/components/Dashboard/Widget/BarChart.vue +73 -0
- package/app/components/Dashboard/Widget/Counter.vue +51 -0
- package/app/components/Dashboard/Widget/LineChart.vue +76 -0
- package/app/components/Dashboard/Widget/PieChart.vue +75 -0
- package/app/components/Dashboard/Widget/Table.vue +94 -0
- package/app/components/Dashboard/WidgetEditor.vue +274 -0
- package/app/components/Data/Array.vue +102 -0
- package/app/components/Data/Html.vue +9 -0
- package/app/components/Data/Icon.vue +9 -0
- package/app/components/Data/Object.vue +10 -0
- package/app/components/Data/S.vue +16 -0
- package/app/components/Data/Table.vue +53 -0
- package/app/components/Data/Value.vue +34 -0
- package/app/components/Data/index.vue +14 -0
- package/app/components/Field/Array.vue +324 -0
- package/app/components/Field/Asset/Actions.vue +97 -0
- package/app/components/Field/Asset/index.vue +639 -0
- package/app/components/Field/Boolean.vue +26 -0
- package/app/components/Field/Checkbox.vue +48 -0
- package/app/components/Field/Color.vue +27 -0
- package/app/components/Field/Date.vue +30 -0
- package/app/components/Field/Email.vue +51 -0
- package/app/components/Field/EmailTemplate.vue +420 -0
- package/app/components/Field/Html/ColorPicker.vue +127 -0
- package/app/components/Field/Html/Editor.vue +544 -0
- package/app/components/Field/Html/index.vue +25 -0
- package/app/components/Field/Icon.vue +114 -0
- package/app/components/Field/JSON.vue +113 -0
- package/app/components/Field/Mention.vue +36 -0
- package/app/components/Field/Number.vue +27 -0
- package/app/components/Field/Object.vue +92 -0
- package/app/components/Field/Password.vue +55 -0
- package/app/components/Field/Radio.vue +47 -0
- package/app/components/Field/S.vue +44 -0
- package/app/components/Field/Select.vue +49 -0
- package/app/components/Field/Table.vue +348 -0
- package/app/components/Field/Tags.vue +113 -0
- package/app/components/Field/Text.vue +31 -0
- package/app/components/Field/Textarea.vue +33 -0
- package/app/components/Field/Url.vue +40 -0
- package/app/components/Field/Wrapper.vue +128 -0
- package/app/components/Field/index.vue +68 -0
- package/app/components/FloatingChatbot.vue +73 -0
- package/app/components/Form/Card.vue +151 -0
- package/app/components/Form/Drawer.vue +195 -0
- package/app/components/Form/index.vue +621 -0
- package/app/components/Header.vue +328 -0
- package/app/components/Offline/ConflictModal.vue +200 -0
- package/app/components/Offline/SyncStatus.vue +249 -0
- package/app/components/PlatformFeatureLanding.vue +65 -0
- package/app/components/Table/Backups.vue +638 -0
- package/app/components/Table/Flows.vue +814 -0
- package/app/components/Table/Grid.vue +296 -0
- package/app/components/Table/Icon.vue +36 -0
- package/app/components/Table/Logs.vue +181 -0
- package/app/components/Table/Schedules.vue +722 -0
- package/app/components/Table/Search/Button.vue +256 -0
- package/app/components/Table/Search/Items.vue +286 -0
- package/app/components/Table/Search/index.vue +87 -0
- package/app/components/Table/Settings/Schema.vue +801 -0
- package/app/components/Table/Settings/index.vue +631 -0
- package/app/components/Table/TranslateDrawer.vue +526 -0
- package/app/components/Table/Views/Kanban.vue +289 -0
- package/app/components/Table/Views/Table.vue +1273 -0
- package/app/components/Table/index.vue +890 -0
- package/app/composables/Translation/define.ts +41 -0
- package/app/composables/Translation/fetch.ts +107 -0
- package/app/composables/Translation/languages.ts +1 -0
- package/app/composables/Translation/loadCoreTranslations.ts +38 -0
- package/app/composables/Translation/translate.ts +250 -0
- package/app/composables/databaseCookies.ts +79 -0
- package/app/composables/fieldValidator.ts +72 -0
- package/app/composables/fieldsList.ts +301 -0
- package/app/composables/formatDatabase.ts +101 -0
- package/app/composables/index.ts +444 -0
- package/app/composables/openDrawer.ts +118 -0
- package/app/composables/optimizeFile.ts +287 -0
- package/app/composables/renderLabel.ts +88 -0
- package/app/composables/schemaClipboard.ts +95 -0
- package/app/composables/search.ts +353 -0
- package/app/composables/setThemeConfig.ts +48 -0
- package/app/composables/translationValue.ts +169 -0
- package/app/composables/useAssetPreview.ts +84 -0
- package/app/composables/useAssetUploader.ts +56 -0
- package/app/composables/useDashboardData.ts +310 -0
- package/app/composables/useDocumentThumbnail.ts +351 -0
- package/app/composables/useEcharts.ts +33 -0
- package/app/composables/useLocalDatabaseConfig.ts +51 -0
- package/app/composables/useOcr.ts +300 -0
- package/app/composables/useOfflineFetch.ts +194 -0
- package/app/composables/useOfflineItem.ts +107 -0
- package/app/composables/useOfflineQueue.ts +302 -0
- package/app/composables/useOfflineSync.ts +235 -0
- package/app/composables/usePasskeyAuth.ts +156 -0
- package/app/composables/usePdfCompressor.ts +180 -0
- package/app/composables/usePlatformRedirect.ts +72 -0
- package/app/composables/useRealtimeSync.ts +233 -0
- package/app/composables/useReferencedItems.ts +180 -0
- package/app/composables/useScanner.ts +664 -0
- package/app/composables/useSubscription.ts +650 -0
- package/app/composables/useThumbnailCache.ts +136 -0
- package/app/composables/useVideoCompressor.ts +194 -0
- package/app/error.vue +112 -0
- package/app/layouts/api.vue +188 -0
- package/app/layouts/dashboard.vue +91 -0
- package/app/layouts/default.vue +228 -0
- package/app/layouts/table.vue +471 -0
- package/app/locales/ar.ts +996 -0
- package/app/locales/en.ts +655 -0
- package/app/locales/es.ts +996 -0
- package/app/locales/fr.ts +997 -0
- package/app/middleware/dashboard.ts +33 -0
- package/app/middleware/database.ts +83 -0
- package/app/middleware/global.ts +1 -0
- package/app/middleware/sid-ingest.global.ts +29 -0
- package/app/middleware/table.ts +54 -0
- package/app/middleware/user.ts +26 -0
- package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
- package/app/pages/[[database]]/admin/api/index.vue +18 -0
- package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
- package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
- package/app/pages/[[database]]/admin/billing/index.vue +16 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
- package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
- package/app/pages/[[database]]/admin/index.vue +28 -0
- package/app/pages/[[database]]/admin/settings.vue +526 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
- package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
- package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
- package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
- package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/index.vue +20 -0
- package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
- package/app/pages/[[database]]/api/index.vue +120 -0
- package/app/pages/[[database]]/auth/index.vue +12 -0
- package/app/pages/[[database]]/auth/reset.vue +10 -0
- package/app/pages/[[database]]/index.vue +10 -0
- package/app/plugins/offline-warmup.client.ts +53 -0
- package/app/plugins/template-globals.ts +9 -0
- package/app/spa-loading-template.html +93 -0
- package/biome.json +53 -0
- package/index.d.ts +380 -0
- package/modules/naiveui.ts +41 -0
- package/nuxt.config.ts +269 -0
- package/package.json +56 -0
- package/pnpm-workspace.yaml +5 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/pwa-192x192.png +0 -0
- package/public/pwa-512x512.png +0 -0
- package/public/pwa-maskable-192x192.png +0 -0
- package/public/pwa-maskable-512x512.png +0 -0
- package/public/vendor/opencv.js +48 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<NTooltip :disabled="hideTooltip || !asset.name">
|
|
3
|
+
<template #trigger>
|
|
4
|
+
<NTooltip :disabled="hideTooltip || !asset.name || !asset.createdAt" :delay="1500">
|
|
5
|
+
<template #trigger>
|
|
6
|
+
<!-- Image -->
|
|
7
|
+
<NImage v-if="asset.publicURL && imageExtensions.includes(asset.extension)" class="asset"
|
|
8
|
+
:src="asset.publicURL" :intersectionObserverOptions lazy
|
|
9
|
+
:preview-disabled="previewDisabled || useSharedPreview"
|
|
10
|
+
:renderToolbar="(props) => renderToolbar(props, asset)" @click="handleAssetClick"
|
|
11
|
+
:width="size" :height="size"
|
|
12
|
+
:style="size ? `width: ${size}px; height: ${size}px;` : undefined"
|
|
13
|
+
:img-props="size ? { style: `width: ${size}px; height: ${size}px;` } : undefined" />
|
|
14
|
+
|
|
15
|
+
<!-- PDF with thumbnail -->
|
|
16
|
+
<NImage v-else-if="asset.extension === 'pdf' && pdfThumbs[assetKey]" class="asset"
|
|
17
|
+
:src="pdfThumbs[assetKey]" :intersectionObserverOptions lazy preview-disabled
|
|
18
|
+
@click="handleAssetClick"
|
|
19
|
+
:width="size" :height="size"
|
|
20
|
+
:style="size ? `width: ${size}px; height: ${size}px;` : undefined"
|
|
21
|
+
:img-props="size ? { style: `width: ${size}px; height: ${size}px;` } : undefined" />
|
|
22
|
+
|
|
23
|
+
<!-- PDF generating thumbnail -->
|
|
24
|
+
<NSpin v-else-if="asset.extension === 'pdf'" :show="generatingPdf[assetKey]" size="small">
|
|
25
|
+
<NIcon class="asset" v-intersect="() => !pdfThumbs[assetKey] && generatePdfThumb()"
|
|
26
|
+
@click="handleAssetClick" :width="size" :height="size">
|
|
27
|
+
<LazyAssetIcon :type="asset.type" class="icon" />
|
|
28
|
+
</NIcon>
|
|
29
|
+
</NSpin>
|
|
30
|
+
|
|
31
|
+
<!-- Video with thumbnail -->
|
|
32
|
+
<NImage v-else-if="videoExtensions.includes(asset.extension) && videoThumbs[assetKey]" class="asset"
|
|
33
|
+
:src="videoThumbs[assetKey]" :intersectionObserverOptions lazy preview-disabled
|
|
34
|
+
@click="handleAssetClick"
|
|
35
|
+
:width="size" :height="size"
|
|
36
|
+
:style="size ? `width: ${size}px; height: ${size}px;` : undefined"
|
|
37
|
+
:img-props="size ? { style: `width: ${size}px; height: ${size}px;` } : undefined" />
|
|
38
|
+
|
|
39
|
+
<!-- Video generating thumbnail -->
|
|
40
|
+
<NSpin v-else-if="videoExtensions.includes(asset.extension)"
|
|
41
|
+
:show="videoExtensions.includes(asset.extension) && generatingVideo[assetKey]" size="small">
|
|
42
|
+
<NIcon class="asset"
|
|
43
|
+
v-intersect="() => videoExtensions.includes(asset.extension) && !videoThumbs[assetKey] && generateVideoThumb()"
|
|
44
|
+
@click="handleAssetClick" :size>
|
|
45
|
+
<LazyAssetIcon :type="asset.type" class="icon" />
|
|
46
|
+
</NIcon>
|
|
47
|
+
</NSpin>
|
|
48
|
+
|
|
49
|
+
<!-- Document with thumbnail (docx, xlsx, pptx, etc.) -->
|
|
50
|
+
<NImage v-else-if="officeExtensions.includes(asset.extension) && docThumbs[assetKey]" class="asset"
|
|
51
|
+
:src="docThumbs[assetKey]" :intersectionObserverOptions lazy preview-disabled
|
|
52
|
+
@click="handleAssetClick"
|
|
53
|
+
:width="size" :height="size"
|
|
54
|
+
:style="size ? `width: ${size}px; height: ${size}px;` : undefined"
|
|
55
|
+
:img-props="size ? { style: `width: ${size}px; height: ${size}px;` } : undefined" />
|
|
56
|
+
|
|
57
|
+
<!-- Document generating thumbnail -->
|
|
58
|
+
<NSpin v-else-if="officeExtensions.includes(asset.extension)"
|
|
59
|
+
:show="generatingDoc[assetKey]" size="small">
|
|
60
|
+
<NIcon class="asset"
|
|
61
|
+
v-intersect="() => officeExtensions.includes(asset.extension) && !docThumbs[assetKey] && generateDocThumb()"
|
|
62
|
+
@click="handleAssetClick" :size>
|
|
63
|
+
<LazyAssetIcon :type="asset.type" class="icon" />
|
|
64
|
+
</NIcon>
|
|
65
|
+
</NSpin>
|
|
66
|
+
|
|
67
|
+
<!-- Other file types as icon -->
|
|
68
|
+
<NIcon v-else class="asset" @click="handleAssetClick" :size="size">
|
|
69
|
+
<LazyAssetIcon :type="asset.type" class="icon" />
|
|
70
|
+
</NIcon>
|
|
71
|
+
</template>
|
|
72
|
+
<div :style="{ direction: Language === 'ar' ? 'rtl' : 'ltr' }">
|
|
73
|
+
<div>
|
|
74
|
+
<strong>{{ t('name') }} :</strong>
|
|
75
|
+
<span>{{ asset.name }}{{ asset.extension ? `.${asset.extension}` :
|
|
76
|
+
''
|
|
77
|
+
}}</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div>
|
|
80
|
+
<strong>{{ t("size") }}: </strong>
|
|
81
|
+
<span>{{ humanFileSize(asset.size) }}</span>
|
|
82
|
+
</div>
|
|
83
|
+
<div>
|
|
84
|
+
<strong>{{ t("type") }}: </strong>
|
|
85
|
+
<span>{{ asset.type }}</span>
|
|
86
|
+
</div>
|
|
87
|
+
<div>
|
|
88
|
+
<strong>{{ t('createdAt') }} :</strong>
|
|
89
|
+
<span>{{ asset.createdAt ? formatDate(asset.createdAt) : '' }}</span>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</NTooltip>
|
|
93
|
+
</template>
|
|
94
|
+
{{ asset.name }}{{ asset.extension ? `.${asset.extension}` : '' }}
|
|
95
|
+
</NTooltip>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<script lang="ts" setup>
|
|
99
|
+
import type { ImageRenderToolbarProps } from "naive-ui";
|
|
100
|
+
import type { Directive, VNodeChild } from "vue";
|
|
101
|
+
import { Icon, NIcon, NTooltip } from "#components";
|
|
102
|
+
import { imageExtensions, videoExtensions, officeExtensions } from "~/composables";
|
|
103
|
+
import {
|
|
104
|
+
getCachedThumbnail,
|
|
105
|
+
cacheThumbnail,
|
|
106
|
+
} from "~/composables/useThumbnailCache";
|
|
107
|
+
import { generateDocumentThumbnail } from "~/composables/useDocumentThumbnail";
|
|
108
|
+
import { useAssetPreview } from "~/composables/useAssetPreview";
|
|
109
|
+
|
|
110
|
+
interface Props {
|
|
111
|
+
asset: Asset;
|
|
112
|
+
containerSelector?: string | null;
|
|
113
|
+
size?: number;
|
|
114
|
+
hideTooltip?: boolean;
|
|
115
|
+
previewDisabled?: boolean;
|
|
116
|
+
disableDefaultClickAction?: boolean;
|
|
117
|
+
previewAssets?: Asset[];
|
|
118
|
+
useSharedPreview?: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const {
|
|
122
|
+
asset,
|
|
123
|
+
containerSelector,
|
|
124
|
+
size,
|
|
125
|
+
hideTooltip,
|
|
126
|
+
previewDisabled = false,
|
|
127
|
+
disableDefaultClickAction = false,
|
|
128
|
+
previewAssets,
|
|
129
|
+
useSharedPreview = true,
|
|
130
|
+
} = defineProps<Props>();
|
|
131
|
+
const emit = defineEmits<{
|
|
132
|
+
(e: "click", event: MouseEvent): void;
|
|
133
|
+
}>();
|
|
134
|
+
|
|
135
|
+
const database = useState<Database>("database");
|
|
136
|
+
const Language = useScopedCookie<LanguagesType>("language", database.value?.slug);
|
|
137
|
+
|
|
138
|
+
const formatDate = (date: string | Date | number) => {
|
|
139
|
+
const d = new Date(date);
|
|
140
|
+
return d.toLocaleString(Language.value, {
|
|
141
|
+
year: 'numeric',
|
|
142
|
+
month: 'short',
|
|
143
|
+
day: 'numeric',
|
|
144
|
+
hour: '2-digit',
|
|
145
|
+
minute: '2-digit'
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const intersectionObserverOptions = containerSelector
|
|
150
|
+
? {
|
|
151
|
+
root: containerSelector,
|
|
152
|
+
}
|
|
153
|
+
: undefined;
|
|
154
|
+
// Video and PDF thumbnail generation state
|
|
155
|
+
const videoThumbs = reactive<Record<string, string | undefined>>({});
|
|
156
|
+
const generatingVideo = reactive<Record<string, boolean>>({});
|
|
157
|
+
const pdfThumbs = reactive<Record<string, string | undefined>>({});
|
|
158
|
+
const generatingPdf = reactive<Record<string, boolean>>({});
|
|
159
|
+
|
|
160
|
+
// Document (Office) thumbnail generation state
|
|
161
|
+
const docThumbs = reactive<Record<string, string | undefined>>({});
|
|
162
|
+
const generatingDoc = reactive<Record<string, boolean>>({});
|
|
163
|
+
|
|
164
|
+
const assetKey = computed(() =>
|
|
165
|
+
String(asset.id ?? asset.publicURL ?? asset.name),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
function assetKeyValue(asset: Asset): string {
|
|
169
|
+
return String(asset.id ?? asset.publicURL ?? asset.name);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function generateVideoThumb(timeSec = 0.5) {
|
|
173
|
+
if (!videoExtensions.includes(asset.extension)) return;
|
|
174
|
+
const key = assetKeyValue(asset);
|
|
175
|
+
if (videoThumbs[key] !== undefined) return videoThumbs[key];
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
generatingVideo[key] = true;
|
|
179
|
+
|
|
180
|
+
// Try to get from cache first
|
|
181
|
+
const cached = await getCachedThumbnail(`video-${key}`);
|
|
182
|
+
if (cached) {
|
|
183
|
+
videoThumbs[key] = cached;
|
|
184
|
+
return cached;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const dataUrl = await new Promise<string | undefined>((resolve) => {
|
|
188
|
+
const video = document.createElement("video");
|
|
189
|
+
// Requires proper CORS headers on the asset URL
|
|
190
|
+
video.crossOrigin = "anonymous";
|
|
191
|
+
video.preload = "metadata";
|
|
192
|
+
video.muted = true;
|
|
193
|
+
// iOS specific: required for inline playback
|
|
194
|
+
video.playsInline = true;
|
|
195
|
+
// iOS specific: disable picture-in-picture
|
|
196
|
+
video.setAttribute("playsinline", "");
|
|
197
|
+
video.setAttribute("webkit-playsinline", "");
|
|
198
|
+
|
|
199
|
+
const cleanup = () => {
|
|
200
|
+
video.removeEventListener("loadedmetadata", onLoadedMetadata);
|
|
201
|
+
video.removeEventListener("loadeddata", onLoadedData);
|
|
202
|
+
video.removeEventListener("seeked", onSeeked);
|
|
203
|
+
video.removeEventListener("error", onError);
|
|
204
|
+
video.removeEventListener("canplay", onCanPlay);
|
|
205
|
+
video.pause();
|
|
206
|
+
video.src = "";
|
|
207
|
+
video.load();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const onError = (error: Event) => {
|
|
211
|
+
console.warn("Video thumbnail generation error:", error);
|
|
212
|
+
cleanup();
|
|
213
|
+
resolve(undefined);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
let metadataLoaded = false;
|
|
217
|
+
let dataLoaded = false;
|
|
218
|
+
|
|
219
|
+
const onLoadedMetadata = () => {
|
|
220
|
+
metadataLoaded = true;
|
|
221
|
+
trySeek();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const onLoadedData = () => {
|
|
225
|
+
dataLoaded = true;
|
|
226
|
+
trySeek();
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const onCanPlay = () => {
|
|
230
|
+
if (!metadataLoaded || !dataLoaded) {
|
|
231
|
+
trySeek();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const trySeek = () => {
|
|
236
|
+
// Wait for both metadata and data to be loaded (or canplay event)
|
|
237
|
+
if (!video.duration || video.duration === Infinity) {
|
|
238
|
+
// Duration not available yet, wait
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const target = Number.isFinite(video.duration)
|
|
244
|
+
? Math.min(timeSec, Math.max(0, video.duration - 0.1))
|
|
245
|
+
: 0;
|
|
246
|
+
|
|
247
|
+
// iOS: Try to play briefly to trigger frame loading
|
|
248
|
+
const playPromise = video.play();
|
|
249
|
+
if (playPromise !== undefined) {
|
|
250
|
+
playPromise
|
|
251
|
+
.then(() => {
|
|
252
|
+
// Pause immediately after play succeeds
|
|
253
|
+
video.pause();
|
|
254
|
+
// Now seek to target time
|
|
255
|
+
if (target > 0 && video.currentTime !== target) {
|
|
256
|
+
video.currentTime = target;
|
|
257
|
+
} else {
|
|
258
|
+
// Already at position 0 or target, capture now
|
|
259
|
+
captureFrame();
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
.catch(() => {
|
|
263
|
+
// Play failed (might be restricted), try direct seek
|
|
264
|
+
if (target > 0) {
|
|
265
|
+
video.currentTime = target;
|
|
266
|
+
} else {
|
|
267
|
+
captureFrame();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
} else {
|
|
271
|
+
// Old browsers without promise
|
|
272
|
+
video.pause();
|
|
273
|
+
if (target > 0) {
|
|
274
|
+
video.currentTime = target;
|
|
275
|
+
} else {
|
|
276
|
+
captureFrame();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (error) {
|
|
280
|
+
console.warn("Error during video seek:", error);
|
|
281
|
+
// Try to capture current frame anyway
|
|
282
|
+
captureFrame();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const captureFrame = () => {
|
|
287
|
+
try {
|
|
288
|
+
const canvas = document.createElement("canvas");
|
|
289
|
+
canvas.width = video.videoWidth || 320;
|
|
290
|
+
canvas.height = video.videoHeight || 180;
|
|
291
|
+
|
|
292
|
+
// Handle case where dimensions are still not available
|
|
293
|
+
if (canvas.width === 0 || canvas.height === 0) {
|
|
294
|
+
canvas.width = 320;
|
|
295
|
+
canvas.height = 180;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const ctx = canvas.getContext("2d");
|
|
299
|
+
if (!ctx) {
|
|
300
|
+
cleanup();
|
|
301
|
+
resolve(undefined);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
305
|
+
const url = canvas.toDataURL("image/jpeg", 0.7);
|
|
306
|
+
cleanup();
|
|
307
|
+
resolve(url);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.warn("Error capturing video frame:", error);
|
|
310
|
+
cleanup();
|
|
311
|
+
resolve(undefined);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const onSeeked = () => {
|
|
316
|
+
captureFrame();
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
video.addEventListener("loadedmetadata", onLoadedMetadata, {
|
|
320
|
+
once: false,
|
|
321
|
+
});
|
|
322
|
+
video.addEventListener("loadeddata", onLoadedData, { once: false });
|
|
323
|
+
video.addEventListener("canplay", onCanPlay, { once: false });
|
|
324
|
+
video.addEventListener("seeked", onSeeked, { once: true });
|
|
325
|
+
video.addEventListener("error", onError, { once: true });
|
|
326
|
+
|
|
327
|
+
// iOS: Must set src before load()
|
|
328
|
+
video.src = asset.publicURL;
|
|
329
|
+
// iOS: Explicitly call load() to start loading metadata
|
|
330
|
+
video.load();
|
|
331
|
+
|
|
332
|
+
// Fallback timeout in case events don't fire
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
if (videoThumbs[key] === undefined) {
|
|
335
|
+
console.warn("Video thumbnail generation timeout");
|
|
336
|
+
cleanup();
|
|
337
|
+
resolve(undefined);
|
|
338
|
+
}
|
|
339
|
+
}, 15000); // 15 second timeout
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
videoThumbs[key] = dataUrl;
|
|
343
|
+
|
|
344
|
+
// Cache the generated thumbnail
|
|
345
|
+
if (dataUrl) await cacheThumbnail(`video-${key}`, dataUrl);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
console.warn("Error in generateVideoThumb:", error);
|
|
348
|
+
videoThumbs[key] = undefined;
|
|
349
|
+
} finally {
|
|
350
|
+
generatingVideo[key] = false;
|
|
351
|
+
}
|
|
352
|
+
return videoThumbs[key];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function generatePdfThumb() {
|
|
356
|
+
if (asset.extension !== "pdf") return;
|
|
357
|
+
const key = assetKeyValue(asset);
|
|
358
|
+
if (pdfThumbs[key] !== undefined) return pdfThumbs[key];
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
generatingPdf[key] = true;
|
|
362
|
+
|
|
363
|
+
// Try to get from cache first
|
|
364
|
+
const cached = await getCachedThumbnail(`pdf-${key}`);
|
|
365
|
+
if (cached) {
|
|
366
|
+
pdfThumbs[key] = cached;
|
|
367
|
+
return cached;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Check file size before attempting to load
|
|
371
|
+
// Limit to 50MB to prevent memory issues with large PDFs
|
|
372
|
+
const maxFileSizeBytes = 50 * 1024 * 1024;
|
|
373
|
+
if (asset.size && asset.size > maxFileSizeBytes) {
|
|
374
|
+
console.warn(
|
|
375
|
+
`PDF file too large (${Math.round(asset.size / 1024 / 1024)}MB). Skipping thumbnail generation.`,
|
|
376
|
+
);
|
|
377
|
+
pdfThumbs[key] = undefined;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Dynamically import pdfjs-dist to avoid bundling issues
|
|
382
|
+
const pdfjsLib = await import("pdfjs-dist");
|
|
383
|
+
// Set worker source from CDN to avoid loading local worker file
|
|
384
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
|
|
385
|
+
|
|
386
|
+
// Create a timeout promise to abort if loading takes too long
|
|
387
|
+
const timeoutPromise: Promise<never> = new Promise((_resolve, reject) => {
|
|
388
|
+
setTimeout(() => reject(new Error("PDF loading timeout")), 30000); // 30 second timeout
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const pdf = await Promise.race([
|
|
392
|
+
pdfjsLib.getDocument({
|
|
393
|
+
url: asset.publicURL as string,
|
|
394
|
+
// Needed so non-embedded/base-14 fonts get correct glyph widths instead of garbled spacing.
|
|
395
|
+
cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/cmaps/`,
|
|
396
|
+
cMapPacked: true,
|
|
397
|
+
standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/standard_fonts/`,
|
|
398
|
+
// Fall back to the system's Arabic/CJK fonts when they aren't embedded in the PDF.
|
|
399
|
+
useSystemFonts: true,
|
|
400
|
+
// Draw embedded glyph outlines directly instead of via @font-face+fillText, which breaks Arabic letter joining for some font subsets.
|
|
401
|
+
disableFontFace: true,
|
|
402
|
+
}).promise as Promise<unknown>,
|
|
403
|
+
timeoutPromise,
|
|
404
|
+
]);
|
|
405
|
+
|
|
406
|
+
if (!pdf || !(pdf as any).numPages || (pdf as any).numPages === 0) {
|
|
407
|
+
pdfThumbs[key] = undefined;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const page = await Promise.race([
|
|
412
|
+
(pdf as any).getPage(1) as Promise<unknown>,
|
|
413
|
+
timeoutPromise,
|
|
414
|
+
]);
|
|
415
|
+
|
|
416
|
+
// Create a smaller square thumbnail (200x200px)
|
|
417
|
+
const thumbnailSize = 200;
|
|
418
|
+
// Render at device pixel ratio so text, images and icons stay sharp instead of blurry.
|
|
419
|
+
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
|
420
|
+
const viewport = (page as any).getViewport({ scale: 1 });
|
|
421
|
+
|
|
422
|
+
// Calculate scale to cover the square (like CSS cover) - use MAX instead of MIN
|
|
423
|
+
const scale = Math.max(
|
|
424
|
+
thumbnailSize / viewport.width,
|
|
425
|
+
thumbnailSize / viewport.height,
|
|
426
|
+
);
|
|
427
|
+
const scaledViewport = (page as any).getViewport({ scale: scale * pixelRatio });
|
|
428
|
+
|
|
429
|
+
const canvas = document.createElement("canvas");
|
|
430
|
+
// Make the canvas square at full resolution, scaled back down via CSS
|
|
431
|
+
canvas.width = thumbnailSize * pixelRatio;
|
|
432
|
+
canvas.height = thumbnailSize * pixelRatio;
|
|
433
|
+
canvas.style.width = `${thumbnailSize}px`;
|
|
434
|
+
canvas.style.height = `${thumbnailSize}px`;
|
|
435
|
+
|
|
436
|
+
const ctx = canvas.getContext("2d");
|
|
437
|
+
if (!ctx) {
|
|
438
|
+
pdfThumbs[key] = undefined;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Center the scaled PDF content in the square canvas (crop excess)
|
|
443
|
+
const offsetX = (canvas.width - scaledViewport.width) / 2;
|
|
444
|
+
const offsetY = (canvas.height - scaledViewport.height) / 2;
|
|
445
|
+
ctx.translate(offsetX, offsetY);
|
|
446
|
+
|
|
447
|
+
await Promise.race([
|
|
448
|
+
(page as any).render({
|
|
449
|
+
canvasContext: ctx,
|
|
450
|
+
viewport: scaledViewport,
|
|
451
|
+
canvas: canvas,
|
|
452
|
+
}).promise as Promise<unknown>,
|
|
453
|
+
timeoutPromise,
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
|
|
457
|
+
pdfThumbs[key] = dataUrl;
|
|
458
|
+
|
|
459
|
+
// Cache the generated thumbnail
|
|
460
|
+
if (dataUrl) await cacheThumbnail(`pdf-${key}`, dataUrl);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.warn(
|
|
463
|
+
`Failed to generate PDF thumbnail for ${asset.publicURL}:`,
|
|
464
|
+
error,
|
|
465
|
+
);
|
|
466
|
+
pdfThumbs[key] = undefined;
|
|
467
|
+
} finally {
|
|
468
|
+
generatingPdf[key] = false;
|
|
469
|
+
}
|
|
470
|
+
return pdfThumbs[key];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function generateDocThumb() {
|
|
474
|
+
if (!officeExtensions.includes(asset.extension)) return;
|
|
475
|
+
const key = assetKeyValue(asset);
|
|
476
|
+
if (docThumbs[key] !== undefined) return docThumbs[key];
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
generatingDoc[key] = true;
|
|
480
|
+
|
|
481
|
+
// Try to get from cache first
|
|
482
|
+
const cached = await getCachedThumbnail(`doc-${key}`);
|
|
483
|
+
if (cached) {
|
|
484
|
+
docThumbs[key] = cached;
|
|
485
|
+
return cached;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const dataUrl = await generateDocumentThumbnail(asset);
|
|
489
|
+
docThumbs[key] = dataUrl;
|
|
490
|
+
return dataUrl;
|
|
491
|
+
} catch (error) {
|
|
492
|
+
console.warn("Error in generateDocThumb:", error);
|
|
493
|
+
docThumbs[key] = undefined;
|
|
494
|
+
} finally {
|
|
495
|
+
generatingDoc[key] = false;
|
|
496
|
+
}
|
|
497
|
+
return docThumbs[key];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Intersection observer directive for lazy actions
|
|
501
|
+
const vIntersect: Directive<HTMLElement, () => void> = {
|
|
502
|
+
mounted(el, binding) {
|
|
503
|
+
const callback = binding.value;
|
|
504
|
+
if (typeof callback !== "function") return;
|
|
505
|
+
const observer = new IntersectionObserver((entries) => {
|
|
506
|
+
for (const entry of entries) {
|
|
507
|
+
if (entry.isIntersecting) {
|
|
508
|
+
callback();
|
|
509
|
+
observer.disconnect();
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
observer.observe(el);
|
|
515
|
+
// store on element for potential cleanup
|
|
516
|
+
(el as any).__io__ = observer;
|
|
517
|
+
},
|
|
518
|
+
unmounted(el) {
|
|
519
|
+
const observer: IntersectionObserver | undefined = (el as any).__io__;
|
|
520
|
+
if (observer) observer.disconnect();
|
|
521
|
+
delete (el as any).__io__;
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const renderToolbar: (
|
|
526
|
+
props: ImageRenderToolbarProps,
|
|
527
|
+
file?: Asset,
|
|
528
|
+
) => VNodeChild = (
|
|
529
|
+
{
|
|
530
|
+
nodes: {
|
|
531
|
+
rotateCounterclockwise,
|
|
532
|
+
rotateClockwise,
|
|
533
|
+
resizeToOriginalSize,
|
|
534
|
+
zoomOut,
|
|
535
|
+
zoomIn,
|
|
536
|
+
download,
|
|
537
|
+
close,
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
file?: Asset,
|
|
541
|
+
) => {
|
|
542
|
+
if (download.props && file?.publicURL)
|
|
543
|
+
download.props.onClick = (event: MouseEvent) => {
|
|
544
|
+
event?.preventDefault();
|
|
545
|
+
window.open(file.publicURL as string, "_blank");
|
|
546
|
+
close?.props?.onClick?.();
|
|
547
|
+
};
|
|
548
|
+
return [
|
|
549
|
+
file?.name
|
|
550
|
+
? h(
|
|
551
|
+
NTooltip,
|
|
552
|
+
{},
|
|
553
|
+
{
|
|
554
|
+
default: () => file.name,
|
|
555
|
+
trigger: () =>
|
|
556
|
+
h(
|
|
557
|
+
"i",
|
|
558
|
+
{ class: "n-base-icon" },
|
|
559
|
+
h(Icon, { name: "tabler:info-circle-filled" }),
|
|
560
|
+
),
|
|
561
|
+
},
|
|
562
|
+
)
|
|
563
|
+
: null,
|
|
564
|
+
rotateCounterclockwise,
|
|
565
|
+
rotateClockwise,
|
|
566
|
+
zoomIn,
|
|
567
|
+
zoomOut,
|
|
568
|
+
resizeToOriginalSize,
|
|
569
|
+
download,
|
|
570
|
+
close,
|
|
571
|
+
];
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const { currentPreviewAsset, openPreview } = useAssetPreview();
|
|
575
|
+
const requiresPreviewDownloadShortcut = computed(
|
|
576
|
+
() => asset.extension === "pdf" || videoExtensions.includes(asset.extension) || officeExtensions.includes(asset.extension),
|
|
577
|
+
);
|
|
578
|
+
const isPreviewingCurrentAsset = computed(() => {
|
|
579
|
+
const previewAsset = currentPreviewAsset.value;
|
|
580
|
+
if (!previewAsset) return false;
|
|
581
|
+
return assetKeyValue(previewAsset) === assetKey.value;
|
|
582
|
+
});
|
|
583
|
+
const shouldListenForDownloadShortcut = computed(
|
|
584
|
+
() => requiresPreviewDownloadShortcut.value && isPreviewingCurrentAsset.value,
|
|
585
|
+
);
|
|
586
|
+
let removePreviewShortcut: (() => void) | undefined;
|
|
587
|
+
|
|
588
|
+
function handleAssetClick(event: MouseEvent) {
|
|
589
|
+
if (event.defaultPrevented) return false;
|
|
590
|
+
|
|
591
|
+
if (disableDefaultClickAction) {
|
|
592
|
+
emit("click", event);
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (asset.type === "dir") {
|
|
597
|
+
emit("click", event);
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (event.ctrlKey || event.metaKey) {
|
|
602
|
+
event.preventDefault();
|
|
603
|
+
event.stopPropagation();
|
|
604
|
+
return window.open(asset.publicURL as string, "_blank");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (imageExtensions.includes(asset.extension)) {
|
|
608
|
+
openPreview(asset, { assets: previewAssets });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (useSharedPreview || asset.extension === "pdf") {
|
|
613
|
+
openPreview(asset, { assets: previewAssets });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (videoExtensions.includes(asset.extension)) {
|
|
618
|
+
openPreview(asset, { assets: previewAssets });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (officeExtensions.includes(asset.extension)) {
|
|
623
|
+
openPreview(asset, { assets: previewAssets });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
emit("click", event);
|
|
628
|
+
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function triggerAssetDownload(file: Asset = asset) {
|
|
633
|
+
if (!file.publicURL || typeof window === "undefined") return;
|
|
634
|
+
const link = document.createElement("a");
|
|
635
|
+
const defaultName = (() => {
|
|
636
|
+
if (file.name) return file.name;
|
|
637
|
+
try {
|
|
638
|
+
const url = new URL(file.publicURL);
|
|
639
|
+
const last = url.pathname.split("/").pop();
|
|
640
|
+
return last || "asset";
|
|
641
|
+
} catch {
|
|
642
|
+
return "asset";
|
|
643
|
+
}
|
|
644
|
+
})();
|
|
645
|
+
link.download = defaultName;
|
|
646
|
+
document.body.appendChild(link);
|
|
647
|
+
|
|
648
|
+
const downloadByOpening = () => {
|
|
649
|
+
const newTab = window.open(file.publicURL as string, "_blank", "noopener");
|
|
650
|
+
if (newTab) {
|
|
651
|
+
// Give the browser a moment to trigger the download before closing the tab
|
|
652
|
+
window.setTimeout(() => {
|
|
653
|
+
try {
|
|
654
|
+
newTab.close();
|
|
655
|
+
} catch {
|
|
656
|
+
/* Ignore cross-origin close errors */
|
|
657
|
+
}
|
|
658
|
+
}, 2000);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
link.href = file.publicURL as string;
|
|
664
|
+
link.rel = "noopener";
|
|
665
|
+
link.click();
|
|
666
|
+
} catch (error) {
|
|
667
|
+
console.warn("Falling back to opening asset in new tab", error);
|
|
668
|
+
downloadByOpening();
|
|
669
|
+
} finally {
|
|
670
|
+
document.body.removeChild(link);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function registerPreviewShortcut() {
|
|
675
|
+
if (!import.meta.client || removePreviewShortcut) return;
|
|
676
|
+
const handler = (event: KeyboardEvent) => {
|
|
677
|
+
if (!(event.metaKey || event.ctrlKey)) return;
|
|
678
|
+
if (event.key?.toLowerCase() !== "s") return;
|
|
679
|
+
event.preventDefault();
|
|
680
|
+
void triggerAssetDownload(currentPreviewAsset.value ?? asset);
|
|
681
|
+
};
|
|
682
|
+
window.addEventListener("keydown", handler);
|
|
683
|
+
removePreviewShortcut = () => window.removeEventListener("keydown", handler);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function cleanupPreviewShortcut() {
|
|
687
|
+
if (!removePreviewShortcut) return;
|
|
688
|
+
removePreviewShortcut();
|
|
689
|
+
removePreviewShortcut = undefined;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
watch(
|
|
693
|
+
shouldListenForDownloadShortcut,
|
|
694
|
+
(shouldListen) => {
|
|
695
|
+
if (!import.meta.client) return;
|
|
696
|
+
if (shouldListen) {
|
|
697
|
+
registerPreviewShortcut();
|
|
698
|
+
} else {
|
|
699
|
+
cleanupPreviewShortcut();
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
{ immediate: true },
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
onBeforeUnmount(() => {
|
|
706
|
+
cleanupPreviewShortcut();
|
|
707
|
+
});
|
|
708
|
+
</script>
|