@opendesign-plus/components 0.0.1-rc.24 → 0.0.1-rc.26

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.
Files changed (80) hide show
  1. package/dist/chunk-OElCookieNotice.cjs.js +1 -1
  2. package/dist/chunk-OElCookieNotice.es.js +111 -88
  3. package/dist/components/OHeaderSearch.vue.d.ts +814 -534
  4. package/dist/components/OHeaderUser.vue.d.ts +1 -1
  5. package/dist/components/OLanguageSwitcher.vue.d.ts +49 -0
  6. package/dist/components/OThemeSwitcher.vue.d.ts +2 -5
  7. package/dist/components/activity/OActivityMyCalendar.vue.d.ts +4 -4
  8. package/dist/components/activity/index.d.ts +2 -2
  9. package/dist/components/banner/OBanner.vue.d.ts +13 -0
  10. package/dist/components/banner/OBannerContent.vue.d.ts +7 -0
  11. package/dist/components/banner/index.d.ts +68 -0
  12. package/dist/components/banner/types.d.ts +31 -0
  13. package/dist/components/meeting/OMeetingCalendar.vue.d.ts +5 -3
  14. package/dist/components/meeting/OMeetingMyCalendar.vue.d.ts +4 -4
  15. package/dist/components/meeting/OMeetingPlayback.vue.d.ts +50 -1
  16. package/dist/components/meeting/components/OMeetingCalendarSelector.vue.d.ts +1 -1
  17. package/dist/components/meeting/components/OMeetingPlaybackSubtitles.vue.d.ts +16 -1
  18. package/dist/components/meeting/composables/useMeetingConfig.d.ts +1 -1
  19. package/dist/components/meeting/index.d.ts +347 -20
  20. package/dist/components/meeting/types.d.ts +1 -1
  21. package/dist/components/search/OSearchInput.vue.d.ts +1005 -0
  22. package/dist/components/search/composables/useImageSearch.d.ts +48 -0
  23. package/dist/components/search/composables/useKeywordHighlight.d.ts +2 -0
  24. package/dist/components/search/composables/useSearchHistory.d.ts +14 -0
  25. package/dist/components/search/index.d.ts +590 -0
  26. package/dist/components/search/internal/HighlightText.vue.d.ts +9 -0
  27. package/dist/components/search/internal/SearchImageInput.vue.d.ts +716 -0
  28. package/dist/components/search/internal/SearchPanel.vue.d.ts +100 -0
  29. package/dist/components/search/types.d.ts +20 -0
  30. package/dist/components.cjs.js +40 -40
  31. package/dist/components.css +1 -1
  32. package/dist/components.es.js +11352 -10056
  33. package/dist/index.d.ts +4 -2
  34. package/package.json +4 -4
  35. package/scripts/generate-components-index.js +1 -1
  36. package/src/assets/styles/element-plus.scss +16 -9
  37. package/src/assets/svg-icons/icon-delete-hover.svg +4 -0
  38. package/src/assets/svg-icons/icon-delete.svg +5 -1
  39. package/src/assets/svg-icons/icon-image-close.svg +4 -0
  40. package/src/assets/svg-icons/icon-image-upload.svg +3 -0
  41. package/src/assets/svg-icons/icon-image-zoomin.svg +3 -0
  42. package/src/assets/svg-icons/icon-refresh.svg +3 -0
  43. package/src/components/OHeaderSearch.vue +445 -418
  44. package/src/components/OLanguageSwitcher.vue +211 -0
  45. package/src/components/OPlusConfigProvider.vue +2 -2
  46. package/src/components/OThemeSwitcher.vue +51 -27
  47. package/src/components/activity/OActivityForm.vue +7 -3
  48. package/src/components/activity/OActivityMyCalendar.vue +16 -7
  49. package/src/components/banner/OBanner.vue +288 -0
  50. package/src/components/banner/OBannerContent.vue +175 -0
  51. package/src/components/banner/index.ts +18 -0
  52. package/src/components/banner/types.ts +39 -0
  53. package/src/components/header/OHeader.vue +1 -1
  54. package/src/components/meeting/OMeetingCalendar.vue +11 -6
  55. package/src/components/meeting/OMeetingForm.vue +55 -9
  56. package/src/components/meeting/OMeetingMyCalendar.vue +17 -14
  57. package/src/components/meeting/OMeetingPlayback.vue +10 -4
  58. package/src/components/meeting/OMeetingSigCalendar.vue +1 -1
  59. package/src/components/meeting/components/OMeetingCalendarList.vue +57 -21
  60. package/src/components/meeting/components/OMeetingCalendarSelector.vue +11 -8
  61. package/src/components/meeting/components/OMeetingDetail.vue +1 -1
  62. package/src/components/meeting/components/OMeetingPlaybackSubtitles.vue +7 -4
  63. package/src/components/meeting/composables/useMeetingConfig.ts +5 -5
  64. package/src/components/meeting/index.ts +2 -2
  65. package/src/components/meeting/types.ts +1 -1
  66. package/src/components/search/OSearchInput.vue +526 -0
  67. package/src/components/search/composables/useImageSearch.ts +157 -0
  68. package/src/components/search/composables/useKeywordHighlight.ts +30 -0
  69. package/src/components/search/composables/useSearchHistory.ts +75 -0
  70. package/src/components/search/index.ts +23 -0
  71. package/src/components/search/internal/HighlightText.vue +37 -0
  72. package/src/components/search/internal/SearchImageInput.vue +498 -0
  73. package/src/components/search/internal/SearchPanel.vue +431 -0
  74. package/src/components/search/types.ts +25 -0
  75. package/src/i18n/en.ts +13 -1
  76. package/src/i18n/zh.ts +14 -3
  77. package/src/index.ts +5 -3
  78. package/vite.config.ts +4 -0
  79. package/dist/components/OBanner.vue.d.ts +0 -11
  80. package/src/components/OBanner.vue +0 -398
@@ -0,0 +1,526 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, toRef, watch } from 'vue';
3
+ import { onClickOutside, useDebounceFn } from '@vueuse/core';
4
+
5
+ import SearchImageInput from './internal/SearchImageInput.vue';
6
+ import SearchPanel from './internal/SearchPanel.vue';
7
+ import { useSearchHistory } from './composables/useSearchHistory';
8
+ import { useI18n } from '@/i18n';
9
+ import type {
10
+ OSearchPayload,
11
+ OSearchRecommendItem,
12
+ OSearchUploadImageFn,
13
+ } from './types';
14
+
15
+ export interface OSearchInputPropsT {
16
+ modelValue?: string;
17
+ imageUrl?: string;
18
+ size?: 'small' | 'medium' | 'large';
19
+ placeholder?: string;
20
+ imagePlaceholder?: string;
21
+
22
+ /** ---- Image search ---- */
23
+ enableImageSearch?: boolean;
24
+ uploadImage?: OSearchUploadImageFn;
25
+ maxImageSize?: number;
26
+ imageUploadTooltip?: string;
27
+ allowedImageTypes?: string[];
28
+
29
+ /** ---- Recommendation lists (controlled by parent) ---- */
30
+ suggestItems?: OSearchRecommendItem[];
31
+ onestepItems?: OSearchRecommendItem[];
32
+ suggestTitle?: string;
33
+ onestepTitle?: string;
34
+ noDataText?: string;
35
+ /** When true, show "no data" placeholder for empty suggest while typing */
36
+ showSuggestEmpty?: boolean;
37
+ highlightKeyword?: boolean;
38
+ debounce?: number;
39
+
40
+ /** ---- History ---- */
41
+ enableHistory?: boolean;
42
+ historyItems?: string[];
43
+ maxHistoryCount?: number;
44
+ storeHistory?: boolean;
45
+ storageKey?: string;
46
+ historyTitle?: string;
47
+ /** Auto-record history on search; default true */
48
+ autoSaveHistory?: boolean;
49
+
50
+ /** ---- Hot searches (shown alongside history when no keyword) ---- */
51
+ hotItems?: string[];
52
+ hotTitle?: string;
53
+
54
+ /** ---- "Did you mean" list (below input) ---- */
55
+ suggestList?: string[];
56
+ suggestListLabel?: string;
57
+
58
+ /** ---- Misc ---- */
59
+ clearable?: boolean;
60
+ closeOnSearch?: boolean;
61
+ closeOnClickOutside?: boolean;
62
+ /** Show dropdown on focus even when empty (history) */
63
+ openOnFocus?: boolean;
64
+ /** Always show image as inline thumbnail inside the input; never expand preview below */
65
+ alwaysInlineThumbnail?: boolean;
66
+ }
67
+
68
+ const props = withDefaults(defineProps<OSearchInputPropsT>(), {
69
+ modelValue: '',
70
+ imageUrl: '',
71
+ size: 'large',
72
+ enableImageSearch: false,
73
+ maxImageSize: 10 * 1024 * 1024,
74
+ suggestItems: () => [],
75
+ onestepItems: () => [],
76
+ showSuggestEmpty: true,
77
+ highlightKeyword: true,
78
+ debounce: 300,
79
+ enableHistory: true,
80
+ historyItems: () => [],
81
+ maxHistoryCount: 6,
82
+ storeHistory: false,
83
+ storageKey: 'search-history',
84
+ autoSaveHistory: true,
85
+ hotItems: () => [],
86
+ suggestList: () => [],
87
+ clearable: true,
88
+ closeOnSearch: true,
89
+ closeOnClickOutside: true,
90
+ openOnFocus: true,
91
+ });
92
+
93
+ const emit = defineEmits<{
94
+ (e: 'update:modelValue', val: string): void;
95
+ (e: 'update:imageUrl', url: string): void;
96
+ (e: 'update:historyItems', items: string[]): void;
97
+ (e: 'focus'): void;
98
+ (e: 'blur'): void;
99
+ (e: 'input', val: string): void;
100
+ (e: 'clear'): void;
101
+ (e: 'search', payload: OSearchPayload): void;
102
+ (e: 'recommend-click', item: OSearchRecommendItem): void;
103
+ (e: 'onestep-click', item: OSearchRecommendItem): void;
104
+ (e: 'history-click', val: string): void;
105
+ (e: 'hot-click', val: string): void;
106
+ (e: 'suggest-list-click', val: string): void;
107
+ (e: 'delete-history', items: string[]): void;
108
+ (e: 'delete-history-item', val: string): void;
109
+ (e: 'image-upload-start', file: File): void;
110
+ (e: 'image-upload-success', url: string, file: File): void;
111
+ (e: 'image-upload-error', error: unknown, file: File): void;
112
+ (e: 'image-validate-error', reason: 'size' | 'type', file: File): void;
113
+ }>();
114
+
115
+ const { t } = useI18n();
116
+
117
+ type SuggestSegment = { text: string; em: boolean };
118
+ function parseSuggest(raw: string): SuggestSegment[] {
119
+ const segments: SuggestSegment[] = [];
120
+ const re = /<em>(.*?)<\/em>/g;
121
+ let last = 0;
122
+ let m: RegExpExecArray | null;
123
+ while ((m = re.exec(raw)) !== null) {
124
+ if (m.index > last) segments.push({ text: raw.slice(last, m.index), em: false });
125
+ segments.push({ text: m[1], em: true });
126
+ last = m.index + m[0].length;
127
+ }
128
+ if (last < raw.length) segments.push({ text: raw.slice(last), em: false });
129
+ return segments;
130
+ }
131
+
132
+ const inputRef = ref<InstanceType<typeof SearchImageInput>>();
133
+ const wrapperRef = ref<HTMLElement | null>(null);
134
+
135
+ const innerValue = computed({
136
+ get: () => props.modelValue,
137
+ set: (val: string) => emit('update:modelValue', val),
138
+ });
139
+
140
+ const isFocus = ref(false);
141
+ const isPreviewOpen = ref(false);
142
+ const justClosedPreview = ref(false);
143
+ const hasInternalImage = ref(false);
144
+
145
+ const showDropdown = computed(() => {
146
+ if (!isFocus.value) return false;
147
+ if (props.imageUrl || hasInternalImage.value) return false;
148
+ const hasSuggest =
149
+ !!innerValue.value &&
150
+ (props.suggestItems.length > 0 || props.onestepItems.length > 0 || props.showSuggestEmpty);
151
+ const hasHistory = props.enableHistory && history.items.value.length > 0 && !innerValue.value;
152
+ const hasHot = props.hotItems.length > 0 && !innerValue.value;
153
+ if (hasSuggest) return true;
154
+ if (props.openOnFocus && (hasHistory || hasHot)) return true;
155
+ return false;
156
+ });
157
+
158
+ // history
159
+ const historyItemsRef = toRef(props, 'historyItems');
160
+ const storageKeyRef = toRef(props, 'storageKey');
161
+ const storeHistoryRef = toRef(props, 'storeHistory');
162
+ const maxHistoryRef = toRef(props, 'maxHistoryCount');
163
+
164
+ const history = useSearchHistory({
165
+ initial: historyItemsRef,
166
+ storageKey: storageKeyRef,
167
+ storeHistory: storeHistoryRef,
168
+ maxHistoryCount: maxHistoryRef,
169
+ onChange: (items) => emit('update:historyItems', items),
170
+ });
171
+
172
+ // debounced input event
173
+ // suppressNextInput: set to true before programmatic value assignments (history/suggest
174
+ // clicks) so the watcher-triggered debounce doesn't fire the input event.
175
+ let suppressNextInput = false;
176
+ const emitInputDebounced = useDebounceFn((val: string) => {
177
+ if (suppressNextInput) {
178
+ suppressNextInput = false;
179
+ return;
180
+ }
181
+ emit('input', val);
182
+ }, () => props.debounce);
183
+
184
+ watch(innerValue, (val) => {
185
+ emitInputDebounced(val);
186
+ });
187
+
188
+ // click outside
189
+ onClickOutside(wrapperRef, () => {
190
+ if (!props.closeOnClickOutside) return;
191
+ if (isPreviewOpen.value || justClosedPreview.value) return;
192
+ isFocus.value = false;
193
+ });
194
+
195
+ const handleFocus = () => {
196
+ isFocus.value = true;
197
+ emit('focus');
198
+ };
199
+
200
+ const handleBlur = () => {
201
+ emit('blur');
202
+ };
203
+
204
+ const runSearch = async (overrideKeyword?: string) => {
205
+ if (inputRef.value?.getIsUploading?.()) {
206
+ await inputRef.value.awaitUpload?.();
207
+ }
208
+ const keyword = overrideKeyword !== undefined ? overrideKeyword.trim() : innerValue.value.trim();
209
+ const imageUrl = inputRef.value?.getUploadedUrl?.() || props.imageUrl;
210
+
211
+ if (!keyword && !imageUrl) return;
212
+
213
+ if (props.autoSaveHistory && keyword) {
214
+ history.push(keyword);
215
+ }
216
+
217
+ if (props.closeOnSearch) {
218
+ isFocus.value = false;
219
+ }
220
+
221
+ emit('search', { keyword, imageUrl: imageUrl || undefined });
222
+ };
223
+
224
+ const handleClear = () => {
225
+ innerValue.value = '';
226
+ emit('clear');
227
+ };
228
+
229
+ const handleSuggestClick = (item: OSearchRecommendItem) => {
230
+ emit('recommend-click', item);
231
+ suppressNextInput = true;
232
+ innerValue.value = item.key;
233
+ runSearch();
234
+ };
235
+
236
+ const handleOnestepClick = (item: OSearchRecommendItem) => {
237
+ emit('onestep-click', item);
238
+ if (item.path) {
239
+ const url = item.path;
240
+ if (typeof window !== 'undefined') window.open(url, '_blank', 'noopener,noreferrer');
241
+ }
242
+ };
243
+
244
+ const handleHistoryClick = (val: string) => {
245
+ emit('history-click', val);
246
+ runSearch(val);
247
+ };
248
+
249
+ const handleHotClick = (val: string) => {
250
+ emit('hot-click', val);
251
+ runSearch(val);
252
+ };
253
+
254
+ const handleHistoryRemove = (val: string) => {
255
+ history.remove(val);
256
+ emit('delete-history-item', val);
257
+ };
258
+
259
+ const handleHistoryClear = () => {
260
+ const removed = [...history.items.value];
261
+ history.clearAll();
262
+ emit('delete-history', removed);
263
+ };
264
+
265
+ const handleSuggestListClick = (val: string) => {
266
+ const text = val.replace(/<[^>]+>/g, '');
267
+ emit('suggest-list-click', text);
268
+ suppressNextInput = true;
269
+ innerValue.value = text;
270
+ runSearch();
271
+ };
272
+
273
+ const onPreviewChange = (visible: boolean) => {
274
+ isPreviewOpen.value = visible;
275
+ if (!visible) {
276
+ justClosedPreview.value = true;
277
+ setTimeout(() => {
278
+ justClosedPreview.value = false;
279
+ }, 100);
280
+ }
281
+ };
282
+
283
+ const showInlineThumbnail = computed(() =>
284
+ !!props.imageUrl && (props.alwaysInlineThumbnail || !isFocus.value)
285
+ );
286
+
287
+ const onImageUploadStart = (file: File) => {
288
+ hasInternalImage.value = true;
289
+ emit('image-upload-start', file);
290
+ };
291
+
292
+ const onImageUploadSuccess = (url: string, file: File) => {
293
+ hasInternalImage.value = false;
294
+ emit('image-upload-success', url, file);
295
+ };
296
+
297
+ const onImageClear = () => {
298
+ hasInternalImage.value = false;
299
+ emit('update:imageUrl', '');
300
+ };
301
+
302
+ defineExpose({
303
+ focus: () => inputRef.value?.focus?.(),
304
+ blur: () => inputRef.value?.blur?.(),
305
+ search: runSearch,
306
+ saveHistory: (val?: string) => history.push(val ?? innerValue.value),
307
+ });
308
+ </script>
309
+
310
+ <template>
311
+ <div ref="wrapperRef" class="o-search-input" :class="{ 'is-focus': isFocus }">
312
+ <div class="o-search-input-box">
313
+ <SearchImageInput
314
+ ref="inputRef"
315
+ v-model="innerValue"
316
+ :image-url="imageUrl"
317
+ :placeholder="placeholder"
318
+ :image-placeholder="imagePlaceholder"
319
+ :size="size"
320
+ :enable-image-search="enableImageSearch"
321
+ :upload-image="uploadImage"
322
+ :max-image-size="maxImageSize"
323
+ :image-upload-tooltip="imageUploadTooltip"
324
+ :allowed-image-types="allowedImageTypes"
325
+ :expanded="isFocus && !props.alwaysInlineThumbnail"
326
+ :inline-thumbnail="showInlineThumbnail"
327
+ :clearable="clearable"
328
+ class="o-search-input-field"
329
+ @update:imageUrl="(url: string) => emit('update:imageUrl', url)"
330
+ @focus="handleFocus"
331
+ @blur="handleBlur"
332
+ @enter="runSearch"
333
+ @clear="handleClear"
334
+ @image-clear="onImageClear"
335
+ @image-upload-start="onImageUploadStart"
336
+ @image-upload-success="onImageUploadSuccess"
337
+ @image-upload-error="(error: unknown, file: File) => emit('image-upload-error', error, file)"
338
+ @image-validate-error="(reason: 'size' | 'type', file: File) => emit('image-validate-error', reason, file)"
339
+ @preview-change="onPreviewChange"
340
+ >
341
+ <template #prefix><slot name="input-prefix" /></template>
342
+ <template #suffix="slotProps"><slot name="input-suffix" v-bind="slotProps" /></template>
343
+ <template v-if="$slots['image-preview']" #preview="slotProps">
344
+ <slot name="image-preview" v-bind="slotProps" />
345
+ </template>
346
+ </SearchImageInput>
347
+
348
+ <Transition name="o-search-input-dropdown">
349
+ <div v-if="showDropdown" class="o-search-input-dropdown">
350
+ <slot name="dropdown" :keyword="modelValue">
351
+ <SearchPanel
352
+ :keyword="modelValue"
353
+ :onestep-items="onestepItems"
354
+ :onestep-title="onestepTitle"
355
+ :suggest-items="suggestItems"
356
+ :suggest-title="suggestTitle"
357
+ :history-items="enableHistory ? history.items.value : []"
358
+ :history-title="historyTitle"
359
+ :hot-items="hotItems"
360
+ :hot-title="hotTitle"
361
+ :no-data-text="noDataText"
362
+ :highlight-keyword="highlightKeyword"
363
+ :hide-on-keyword="true"
364
+ :show-suggest-empty="showSuggestEmpty"
365
+ history-layout="list"
366
+ @onestep-click="handleOnestepClick"
367
+ @suggest-click="handleSuggestClick"
368
+ @history-click="handleHistoryClick"
369
+ @history-remove="handleHistoryRemove"
370
+ @history-clear="handleHistoryClear"
371
+ @hot-click="handleHotClick"
372
+ >
373
+ <template v-if="$slots['onestep-header']" #onestep-header="slotProps">
374
+ <slot name="onestep-header" v-bind="slotProps" />
375
+ </template>
376
+ <template v-if="$slots['onestep-content']" #onestep-content="slotProps">
377
+ <slot name="onestep-content" v-bind="slotProps" />
378
+ </template>
379
+ <template v-if="$slots['suggest-header']" #suggest-header="slotProps">
380
+ <slot name="suggest-header" v-bind="slotProps" />
381
+ </template>
382
+ <template v-if="$slots['suggest-content']" #suggest-content="slotProps">
383
+ <slot name="suggest-content" v-bind="slotProps" />
384
+ </template>
385
+ <template v-if="$slots['history-header']" #history-header="slotProps">
386
+ <slot name="history-header" v-bind="slotProps" />
387
+ </template>
388
+ <template v-if="$slots['history-content']" #history-content="slotProps">
389
+ <slot name="history-content" v-bind="slotProps" />
390
+ </template>
391
+ </SearchPanel>
392
+ </slot>
393
+ </div>
394
+ </Transition>
395
+ </div>
396
+
397
+ <div v-if="suggestList?.length" class="o-search-input-suggest-list-row">
398
+ <slot name="suggest-list" :items="suggestList">
399
+ <span class="o-search-input-suggest-list-label">{{ suggestListLabel ?? t('search.suggestListLabel') }}</span>
400
+ <ul class="o-search-input-suggest-list">
401
+ <li
402
+ v-for="(item, idx) in suggestList"
403
+ :key="item + idx"
404
+ class="o-search-input-suggest-list-item"
405
+ @click="handleSuggestListClick(item)"
406
+ >
407
+ <template v-for="seg in parseSuggest(item)" :key="seg.text + String(seg.em)">
408
+ <em v-if="seg.em">{{ seg.text }}</em>
409
+ <template v-else>{{ seg.text }}</template>
410
+ </template>
411
+ </li>
412
+ </ul>
413
+ </slot>
414
+ </div>
415
+ </div>
416
+ </template>
417
+
418
+ <style lang="scss" scoped>
419
+ .o-search-input {
420
+ position: relative;
421
+ width: 100%;
422
+ background-color: var(--o-color-fill2);
423
+ border-radius: var(--o-radius-xs);
424
+
425
+ :deep(.o-input.o_box-large) {
426
+ --_box-height: 48px;
427
+ }
428
+ :deep(.o-search-image-input-icon) {
429
+ font-size: 24px;
430
+ }
431
+ :deep(.o-search-image-input-upload-btn) {
432
+ width: 24px;
433
+ height: 24px;
434
+ }
435
+
436
+ @include respond-to('pad_v-pc_s') {
437
+ :deep(.o-input.o_box-large) {
438
+ --_box-height: 40px;
439
+ }
440
+ }
441
+
442
+ @include respond-to('<=pad_v') {
443
+ :deep(.o-input.o_box-large) {
444
+ --_box-height: 32px;
445
+ }
446
+ :deep(.o-search-image-input-icon) {
447
+ font-size: 16px;
448
+ }
449
+ :deep(.o-search-image-input-upload-btn) {
450
+ width: 16px;
451
+ height: 16px;
452
+ }
453
+ }
454
+ }
455
+
456
+ .o-search-input-box {
457
+ position: relative;
458
+ width: 100%;
459
+ }
460
+
461
+ .o-search-input-field {
462
+ width: 100%;
463
+ }
464
+
465
+ .o-search-input-dropdown {
466
+ position: absolute;
467
+ top: calc(100% + 4px);
468
+ left: 0;
469
+ right: 0;
470
+ z-index: 10;
471
+ padding: var(--o-gap-4);
472
+ background-color: var(--o-color-fill2);
473
+ border-radius: var(--o-radius-xs);
474
+ box-shadow: var(--o-shadow-2);
475
+ }
476
+
477
+ .o-search-input-dropdown-enter-active,
478
+ .o-search-input-dropdown-leave-active {
479
+ transition: opacity var(--o-duration-m1), transform var(--o-duration-m1);
480
+ }
481
+
482
+ .o-search-input-dropdown-enter-from,
483
+ .o-search-input-dropdown-leave-to {
484
+ opacity: 0;
485
+ transform: translateY(-4px);
486
+ }
487
+
488
+ .o-search-input-suggest-list-row {
489
+ display: flex;
490
+ margin-top: 8px;
491
+ align-items: center;
492
+ flex-wrap: wrap;
493
+ @include tip1;
494
+ color: var(--o-color-info1);
495
+ }
496
+
497
+ .o-search-input-suggest-list-label {
498
+ color: var(--o-color-info3);
499
+ margin-right: 4px;
500
+ @include tip1;
501
+ }
502
+
503
+ .o-search-input-suggest-list {
504
+ display: flex;
505
+ flex-wrap: wrap;
506
+ align-items: center;
507
+ padding: 0;
508
+ margin: 0;
509
+ list-style: none;
510
+ }
511
+
512
+ .o-search-input-suggest-list-item {
513
+ margin-right: 8px;
514
+ cursor: pointer;
515
+ color: var(--o-color-primary1);
516
+
517
+ :deep(em) {
518
+ color: var(--o-color-primary1);
519
+ font-style: normal;
520
+ }
521
+
522
+ @include hover {
523
+ text-decoration: underline;
524
+ }
525
+ }
526
+ </style>
@@ -0,0 +1,157 @@
1
+ import { ref, onBeforeUnmount, type Ref } from 'vue';
2
+ import type { OSearchUploadImageFn } from '../types';
3
+
4
+ export interface UseImageSearchOptions {
5
+ uploadImage?: Ref<OSearchUploadImageFn | undefined>;
6
+ maxImageSize: Ref<number>;
7
+ allowedImageTypes?: Ref<string[]>;
8
+ onUploadStart?: (file: File) => void;
9
+ onUploadSuccess?: (url: string, file: File) => void;
10
+ onUploadError?: (error: unknown, file: File) => void;
11
+ onValidationError?: (reason: 'size' | 'type', file: File) => void;
12
+ }
13
+
14
+ export function useImageSearch(options: UseImageSearchOptions) {
15
+ const previewUrl = ref('');
16
+ const file = ref<File | null>(null);
17
+ const uploadedUrl = ref('');
18
+ const isUploading = ref(false);
19
+ let uploadPromise: Promise<void> | null = null;
20
+
21
+ const validate = (f: File) => {
22
+ const allowed = options.allowedImageTypes?.value;
23
+ const typeOk = allowed && allowed.length > 0 ? allowed.includes(f.type) : f.type?.startsWith('image/');
24
+ if (!f.type || !typeOk) {
25
+ options.onValidationError?.('type', f);
26
+ return false;
27
+ }
28
+ if (options.maxImageSize.value > 0 && f.size > options.maxImageSize.value) {
29
+ options.onValidationError?.('size', f);
30
+ return false;
31
+ }
32
+ return true;
33
+ };
34
+
35
+ const reset = () => {
36
+ if (previewUrl.value) {
37
+ try {
38
+ URL.revokeObjectURL(previewUrl.value);
39
+ } catch {
40
+ // ignore
41
+ }
42
+ }
43
+ previewUrl.value = '';
44
+ file.value = null;
45
+ uploadedUrl.value = '';
46
+ isUploading.value = false;
47
+ uploadPromise = null;
48
+ };
49
+
50
+ const setExternalUrl = (url: string) => {
51
+ if (!url) {
52
+ if (!file.value) reset();
53
+ return;
54
+ }
55
+ if (uploadedUrl.value === url) return;
56
+ if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
57
+ try {
58
+ URL.revokeObjectURL(previewUrl.value);
59
+ } catch {
60
+ // ignore
61
+ }
62
+ }
63
+ uploadedUrl.value = url;
64
+ previewUrl.value = url;
65
+ file.value = null;
66
+ };
67
+
68
+ const handleImageFile = (f: File) => {
69
+ if (!validate(f)) return;
70
+
71
+ reset();
72
+ file.value = f;
73
+ previewUrl.value = URL.createObjectURL(f);
74
+ options.onUploadStart?.(f);
75
+
76
+ const fn = options.uploadImage?.value;
77
+ if (!fn) {
78
+ return;
79
+ }
80
+
81
+ isUploading.value = true;
82
+ uploadPromise = Promise.resolve()
83
+ .then(() => fn(f))
84
+ .then((url) => {
85
+ if (url && file.value === f) {
86
+ uploadedUrl.value = url;
87
+ options.onUploadSuccess?.(url, f);
88
+ }
89
+ })
90
+ .catch((err) => {
91
+ options.onUploadError?.(err, f);
92
+ })
93
+ .finally(() => {
94
+ isUploading.value = false;
95
+ });
96
+ };
97
+
98
+ const onPaste = (event: ClipboardEvent) => {
99
+ const items = event.clipboardData?.items;
100
+ if (!items) return;
101
+ for (let i = 0; i < items.length; i++) {
102
+ const item = items[i];
103
+ if (item.type.startsWith('image/')) {
104
+ const f = item.getAsFile();
105
+ if (f) handleImageFile(f);
106
+ break;
107
+ }
108
+ }
109
+ };
110
+
111
+ const onDragOver = (event: DragEvent) => {
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ };
115
+
116
+ const onDrop = (event: DragEvent) => {
117
+ event.preventDefault();
118
+ event.stopPropagation();
119
+ const f = event.dataTransfer?.files?.[0];
120
+ if (f && f.type.startsWith('image/')) {
121
+ handleImageFile(f);
122
+ }
123
+ };
124
+
125
+ const onFileChange = (event: Event) => {
126
+ const target = event.target as HTMLInputElement;
127
+ const f = target.files?.[0];
128
+ if (f) handleImageFile(f);
129
+ if (target) target.value = '';
130
+ };
131
+
132
+ const pickFile = (input: HTMLInputElement | undefined) => {
133
+ if (!input) return;
134
+ input.value = '';
135
+ input.click();
136
+ };
137
+
138
+ const awaitUpload = () => uploadPromise ?? Promise.resolve();
139
+
140
+ onBeforeUnmount(reset);
141
+
142
+ return {
143
+ previewUrl,
144
+ file,
145
+ uploadedUrl,
146
+ isUploading,
147
+ onPaste,
148
+ onDrop,
149
+ onDragOver,
150
+ onFileChange,
151
+ pickFile,
152
+ handleImageFile,
153
+ reset,
154
+ setExternalUrl,
155
+ awaitUpload,
156
+ };
157
+ }
@@ -0,0 +1,30 @@
1
+ import type { OSearchHighlightPart } from '../types';
2
+
3
+ const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
4
+
5
+ export function highlightKeywordParts(text: string, keyword: string): OSearchHighlightPart[] {
6
+ const k = (keyword || '').trim();
7
+ if (!k) return [{ text, match: false }];
8
+
9
+ const escaped = k.replace(ESCAPE_RE, '\\$&');
10
+ const re = new RegExp(escaped, 'gi');
11
+ const parts: OSearchHighlightPart[] = [];
12
+ let lastIndex = 0;
13
+ let m: RegExpExecArray | null;
14
+
15
+ while ((m = re.exec(text)) !== null) {
16
+ if (m.index === re.lastIndex) {
17
+ re.lastIndex++;
18
+ continue;
19
+ }
20
+ if (m.index > lastIndex) {
21
+ parts.push({ text: text.slice(lastIndex, m.index), match: false });
22
+ }
23
+ parts.push({ text: m[0], match: true });
24
+ lastIndex = m.index + m[0].length;
25
+ }
26
+ if (lastIndex < text.length) {
27
+ parts.push({ text: text.slice(lastIndex), match: false });
28
+ }
29
+ return parts;
30
+ }