@celestia-island/hikari 0.4.4

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 (187) hide show
  1. package/README.md +54 -0
  2. package/package.json +63 -0
  3. package/src/DemoApp.vue +400 -0
  4. package/src/components/HkActionBar.tsx +15 -0
  5. package/src/components/HkAlert.scss +121 -0
  6. package/src/components/HkAlert.tsx +85 -0
  7. package/src/components/HkAvatar.tsx +29 -0
  8. package/src/components/HkBadge.scss +117 -0
  9. package/src/components/HkBadge.tsx +50 -0
  10. package/src/components/HkBreadcrumb.tsx +39 -0
  11. package/src/components/HkButton.scss +128 -0
  12. package/src/components/HkButton.tsx +74 -0
  13. package/src/components/HkCard.scss +49 -0
  14. package/src/components/HkCard.tsx +38 -0
  15. package/src/components/HkCheckbox.scss +102 -0
  16. package/src/components/HkCheckbox.tsx +85 -0
  17. package/src/components/HkCollapse.tsx +48 -0
  18. package/src/components/HkColorPicker.scss +148 -0
  19. package/src/components/HkColorPicker.tsx +204 -0
  20. package/src/components/HkConfirmDialog.scss +12 -0
  21. package/src/components/HkConfirmDialog.tsx +75 -0
  22. package/src/components/HkDateTimePicker.scss +361 -0
  23. package/src/components/HkDateTimePicker.tsx +424 -0
  24. package/src/components/HkDivider.scss +85 -0
  25. package/src/components/HkDivider.tsx +51 -0
  26. package/src/components/HkDraggableGrid.scss +132 -0
  27. package/src/components/HkDraggableGrid.tsx +291 -0
  28. package/src/components/HkDraggableList.scss +110 -0
  29. package/src/components/HkDraggableList.tsx +238 -0
  30. package/src/components/HkDrawer.scss +175 -0
  31. package/src/components/HkDrawer.tsx +171 -0
  32. package/src/components/HkEmptyState.scss +42 -0
  33. package/src/components/HkEmptyState.tsx +41 -0
  34. package/src/components/HkErrorBoundary.scss +64 -0
  35. package/src/components/HkErrorBoundary.tsx +82 -0
  36. package/src/components/HkGaugeRing.scss +4 -0
  37. package/src/components/HkGaugeRing.tsx +130 -0
  38. package/src/components/HkHoverRevealAction.scss +66 -0
  39. package/src/components/HkHoverRevealAction.tsx +77 -0
  40. package/src/components/HkIcon.tsx +43 -0
  41. package/src/components/HkIconButton.tsx +42 -0
  42. package/src/components/HkImageViewer.scss +33 -0
  43. package/src/components/HkImageViewer.tsx +250 -0
  44. package/src/components/HkInput.scss +172 -0
  45. package/src/components/HkInput.tsx +175 -0
  46. package/src/components/HkKbd.tsx +26 -0
  47. package/src/components/HkKeywordSearchModal.scss +148 -0
  48. package/src/components/HkKeywordSearchModal.tsx +308 -0
  49. package/src/components/HkListTransition.scss +131 -0
  50. package/src/components/HkListTransition.tsx +29 -0
  51. package/src/components/HkLocalePickerPopup.tsx +42 -0
  52. package/src/components/HkLogo.scss +36 -0
  53. package/src/components/HkLogo.tsx +93 -0
  54. package/src/components/HkMarkdownRenderer.scss +141 -0
  55. package/src/components/HkMarkdownRenderer.tsx +123 -0
  56. package/src/components/HkMediaControlBar.scss +65 -0
  57. package/src/components/HkMediaControlBar.tsx +115 -0
  58. package/src/components/HkMediaPlayer.scss +29 -0
  59. package/src/components/HkMediaPlayer.tsx +210 -0
  60. package/src/components/HkMediaSlider.scss +54 -0
  61. package/src/components/HkMediaSlider.tsx +81 -0
  62. package/src/components/HkMediaVisualizer.scss +7 -0
  63. package/src/components/HkMediaVisualizer.tsx +119 -0
  64. package/src/components/HkMinimap.scss +76 -0
  65. package/src/components/HkMinimap.tsx +255 -0
  66. package/src/components/HkModal.scss +318 -0
  67. package/src/components/HkModal.tsx +481 -0
  68. package/src/components/HkModalBreadcrumb.scss +55 -0
  69. package/src/components/HkModalBreadcrumb.tsx +113 -0
  70. package/src/components/HkMorphingTabs.scss +141 -0
  71. package/src/components/HkMorphingTabs.tsx +113 -0
  72. package/src/components/HkNavItem.scss +81 -0
  73. package/src/components/HkNavItem.tsx +157 -0
  74. package/src/components/HkNumberInput.scss +154 -0
  75. package/src/components/HkNumberInput.tsx +161 -0
  76. package/src/components/HkPasswordInput.scss +289 -0
  77. package/src/components/HkPasswordInput.tsx +533 -0
  78. package/src/components/HkPhaseTransition.scss +25 -0
  79. package/src/components/HkPhaseTransition.tsx +40 -0
  80. package/src/components/HkPopover.scss +82 -0
  81. package/src/components/HkPopover.tsx +410 -0
  82. package/src/components/HkPopupSelect.scss +136 -0
  83. package/src/components/HkPopupSelect.tsx +199 -0
  84. package/src/components/HkProgressBar.scss +106 -0
  85. package/src/components/HkProgressBar.tsx +61 -0
  86. package/src/components/HkProgressDialog.tsx +67 -0
  87. package/src/components/HkProgressRing.scss +56 -0
  88. package/src/components/HkProgressRing.tsx +82 -0
  89. package/src/components/HkRadio.scss +119 -0
  90. package/src/components/HkRadio.tsx +75 -0
  91. package/src/components/HkRollingNumber.scss +37 -0
  92. package/src/components/HkRollingNumber.tsx +97 -0
  93. package/src/components/HkScrollContainer.scss +101 -0
  94. package/src/components/HkScrollContainer.tsx +362 -0
  95. package/src/components/HkSearchInput.scss +132 -0
  96. package/src/components/HkSearchInput.tsx +117 -0
  97. package/src/components/HkSelect.scss +143 -0
  98. package/src/components/HkSelect.tsx +230 -0
  99. package/src/components/HkSelectionGrid.scss +177 -0
  100. package/src/components/HkSelectionGrid.tsx +99 -0
  101. package/src/components/HkSelectionWaterfall.scss +93 -0
  102. package/src/components/HkSelectionWaterfall.tsx +90 -0
  103. package/src/components/HkSidebar.scss +81 -0
  104. package/src/components/HkSidebar.tsx +45 -0
  105. package/src/components/HkSkeleton.scss +44 -0
  106. package/src/components/HkSkeleton.tsx +30 -0
  107. package/src/components/HkSkeletonList.tsx +35 -0
  108. package/src/components/HkSpinner.scss +67 -0
  109. package/src/components/HkSpinner.tsx +41 -0
  110. package/src/components/HkSplash.scss +60 -0
  111. package/src/components/HkSplash.tsx +40 -0
  112. package/src/components/HkSwitch.scss +157 -0
  113. package/src/components/HkSwitch.tsx +72 -0
  114. package/src/components/HkTable.scss +198 -0
  115. package/src/components/HkTable.tsx +246 -0
  116. package/src/components/HkTabs.scss +109 -0
  117. package/src/components/HkTabs.tsx +111 -0
  118. package/src/components/HkTag.scss +88 -0
  119. package/src/components/HkTag.tsx +44 -0
  120. package/src/components/HkTextarea.scss +72 -0
  121. package/src/components/HkTextarea.tsx +104 -0
  122. package/src/components/HkTimeline.scss +139 -0
  123. package/src/components/HkTimeline.tsx +95 -0
  124. package/src/components/HkToast.scss +168 -0
  125. package/src/components/HkToast.tsx +175 -0
  126. package/src/components/HkTooltip.scss +47 -0
  127. package/src/components/HkTooltip.tsx +108 -0
  128. package/src/components/HkTree.scss +188 -0
  129. package/src/components/HkTree.tsx +217 -0
  130. package/src/components/HkTrendChart.tsx +180 -0
  131. package/src/components/HkWindowedItem.scss +17 -0
  132. package/src/components/HkWindowedItem.tsx +107 -0
  133. package/src/components/HkZoomToolbar.scss +35 -0
  134. package/src/components/HkZoomToolbar.tsx +69 -0
  135. package/src/composables/useBreakpoint.ts +41 -0
  136. package/src/composables/useClipboard.ts +37 -0
  137. package/src/composables/useConfirm.ts +35 -0
  138. package/src/composables/useProgressDialog.ts +52 -0
  139. package/src/composables/useReportedTransition.ts +69 -0
  140. package/src/composables/useScrollWindow.ts +21 -0
  141. package/src/composables/useToast.ts +2 -0
  142. package/src/demo.scss +24 -0
  143. package/src/demo.ts +22 -0
  144. package/src/env.d.ts +6 -0
  145. package/src/i18n/context.ts +89 -0
  146. package/src/i18n/locales/ar/components.json +43 -0
  147. package/src/i18n/locales/ar/components.toml +40 -0
  148. package/src/i18n/locales/de/components.json +43 -0
  149. package/src/i18n/locales/de/components.toml +40 -0
  150. package/src/i18n/locales/en/components.json +43 -0
  151. package/src/i18n/locales/en/components.toml +40 -0
  152. package/src/i18n/locales/es/components.json +43 -0
  153. package/src/i18n/locales/es/components.toml +40 -0
  154. package/src/i18n/locales/fr/components.json +43 -0
  155. package/src/i18n/locales/fr/components.toml +40 -0
  156. package/src/i18n/locales/ja/components.json +43 -0
  157. package/src/i18n/locales/ja/components.toml +40 -0
  158. package/src/i18n/locales/ko/components.json +43 -0
  159. package/src/i18n/locales/ko/components.toml +40 -0
  160. package/src/i18n/locales/pt/components.json +43 -0
  161. package/src/i18n/locales/pt/components.toml +40 -0
  162. package/src/i18n/locales/ru/components.json +43 -0
  163. package/src/i18n/locales/ru/components.toml +40 -0
  164. package/src/i18n/locales/zh-Hans/components.json +43 -0
  165. package/src/i18n/locales/zh-Hans/components.toml +40 -0
  166. package/src/i18n/locales/zh-Hant/components.json +43 -0
  167. package/src/i18n/locales/zh-Hant/components.toml +40 -0
  168. package/src/index.ts +137 -0
  169. package/src/render.ts +18 -0
  170. package/src/runtime/animationBus.ts +278 -0
  171. package/src/runtime/cronBus.ts +25 -0
  172. package/src/runtime/index.ts +12 -0
  173. package/src/runtime/useAsyncData.ts +30 -0
  174. package/src/runtime/useBreakpoint.ts +62 -0
  175. package/src/runtime/useClipboard.ts +37 -0
  176. package/src/runtime/useConfirm.ts +35 -0
  177. package/src/runtime/useOverlay.ts +82 -0
  178. package/src/runtime/usePopupManager.ts +89 -0
  179. package/src/runtime/useToast.ts +140 -0
  180. package/src/styles/index.scss +10 -0
  181. package/src/theme/index.ts +5 -0
  182. package/src/theme/presets.ts +307 -0
  183. package/src/theme/theme.scss +17 -0
  184. package/src/theme/useBackgroundLuminance.ts +145 -0
  185. package/src/theme/useSolarTime.ts +101 -0
  186. package/src/theme/useTheme.ts +141 -0
  187. package/src/tokens.scss +26 -0
@@ -0,0 +1,130 @@
1
+ import { computed, defineComponent, onMounted, ref, watch } from "vue";
2
+
3
+ import { useReportedTransition } from "../composables/useReportedTransition";
4
+ import { onceFrame } from "../runtime/animationBus";
5
+ import "./HkGaugeRing.scss";
6
+
7
+ interface RingData {
8
+ pct: number;
9
+ color: string;
10
+ trackColor: string;
11
+ }
12
+
13
+ const RING_ANIM_MS = 800;
14
+
15
+ export default defineComponent({
16
+ name: "HkGaugeRing",
17
+ props: {
18
+ rings: {
19
+ type: Array as () => RingData[],
20
+ required: true,
21
+ },
22
+ size: { type: Number, default: 140 },
23
+ strokeWidth: { type: Number, default: 8 },
24
+ gap: { type: Number, default: 6 },
25
+ centerLabel: { type: String, default: "" },
26
+ centerValue: { type: String, default: "" },
27
+ animate: { type: Boolean, default: true },
28
+ },
29
+ setup(props) {
30
+ const progress = ref(props.animate ? 0 : 1);
31
+
32
+ const anim = useReportedTransition(RING_ANIM_MS);
33
+ function kickRamp() {
34
+ progress.value = 0;
35
+ onceFrame(() => {
36
+ progress.value = 1;
37
+ });
38
+ anim.run();
39
+ }
40
+
41
+ onMounted(() => {
42
+ if (!props.animate) return;
43
+ kickRamp();
44
+ });
45
+
46
+ watch(() => props.rings, () => {
47
+ if (!props.animate) return;
48
+ kickRamp();
49
+ });
50
+
51
+ const rings = computed(() => {
52
+ const { strokeWidth, gap, size } = props;
53
+ const svg: {
54
+ radius: number;
55
+ circumference: number;
56
+ dashOffset: number;
57
+ color: string;
58
+ trackColor: string;
59
+ strokeWidth: number;
60
+ }[] = [];
61
+ let r = (size - strokeWidth) / 2;
62
+ for (const ring of props.rings) {
63
+ const radius = Math.max(r, 1);
64
+ const circumference = 2 * Math.PI * radius;
65
+ const dashOffset = circumference * (1 - (Math.min(ring.pct, 100) / 100) * progress.value);
66
+ svg.push({
67
+ radius,
68
+ circumference,
69
+ dashOffset,
70
+ color: ring.color,
71
+ trackColor: ring.trackColor,
72
+ strokeWidth,
73
+ });
74
+ r -= strokeWidth + gap;
75
+ if (r < strokeWidth / 2) break;
76
+ }
77
+ return svg;
78
+ });
79
+
80
+ const center = computed(() => props.size / 2);
81
+
82
+ return () => (
83
+ <div class="hk-gauge-ring" style={{ width: `${props.size}px`, height: `${props.size}px` }}>
84
+ <svg
85
+ width={props.size}
86
+ height={props.size}
87
+ viewBox={`0 0 ${props.size} ${props.size}`}
88
+ style={{ transform: "rotate(-90deg)" }}
89
+ >
90
+ {rings.value.map((ring, i) => (
91
+ <g key={i}>
92
+ <circle
93
+ cx={center.value}
94
+ cy={center.value}
95
+ r={ring.radius}
96
+ fill="none"
97
+ stroke={ring.trackColor}
98
+ stroke-width={ring.strokeWidth}
99
+ />
100
+ <circle
101
+ cx={center.value}
102
+ cy={center.value}
103
+ r={ring.radius}
104
+ fill="none"
105
+ stroke={ring.color}
106
+ stroke-width={ring.strokeWidth}
107
+ stroke-linecap="round"
108
+ stroke-dasharray={ring.circumference}
109
+ stroke-dashoffset={ring.dashOffset}
110
+ style={{
111
+ transition: props.animate ? "stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1)" : "none",
112
+ }}
113
+ />
114
+ </g>
115
+ ))}
116
+ </svg>
117
+ {(props.centerValue || props.centerLabel) && (
118
+ <div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
119
+ {props.centerValue && (
120
+ <div class="text-lg font-bold text-text leading-none">{props.centerValue}</div>
121
+ )}
122
+ {props.centerLabel && (
123
+ <div class="text-2xs text-muted mt-0.5">{props.centerLabel}</div>
124
+ )}
125
+ </div>
126
+ )}
127
+ </div>
128
+ );
129
+ },
130
+ });
@@ -0,0 +1,66 @@
1
+ .hk-hover-reveal {
2
+ position: relative;
3
+ display: inline-flex;
4
+ align-items: center;
5
+ }
6
+
7
+ .hk-hover-reveal-main {
8
+ display: inline-flex;
9
+ align-items: center;
10
+ min-width: 0;
11
+ }
12
+
13
+ .hk-hover-reveal-extension {
14
+ position: absolute;
15
+ display: inline-flex;
16
+ align-items: center;
17
+ opacity: 0;
18
+ pointer-events: none;
19
+ transition:
20
+ opacity 0.15s ease,
21
+ transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
22
+ }
23
+
24
+ .hk-hover-reveal[data-placement="right"] > .hk-hover-reveal-extension {
25
+ left: 100%;
26
+ top: 50%;
27
+ transform: translate(-4px, -50%);
28
+ }
29
+ .hk-hover-reveal[data-placement="right"].is-revealed > .hk-hover-reveal-extension {
30
+ opacity: 1;
31
+ pointer-events: auto;
32
+ transform: translate(4px, -50%);
33
+ }
34
+
35
+ .hk-hover-reveal[data-placement="left"] > .hk-hover-reveal-extension {
36
+ right: 100%;
37
+ top: 50%;
38
+ transform: translate(4px, -50%);
39
+ }
40
+ .hk-hover-reveal[data-placement="left"].is-revealed > .hk-hover-reveal-extension {
41
+ opacity: 1;
42
+ pointer-events: auto;
43
+ transform: translate(-4px, -50%);
44
+ }
45
+
46
+ .hk-hover-reveal[data-placement="bottom"] > .hk-hover-reveal-extension {
47
+ left: 50%;
48
+ top: 100%;
49
+ transform: translate(-50%, -4px);
50
+ }
51
+ .hk-hover-reveal[data-placement="bottom"].is-revealed > .hk-hover-reveal-extension {
52
+ opacity: 1;
53
+ pointer-events: auto;
54
+ transform: translate(-50%, 4px);
55
+ }
56
+
57
+ .hk-hover-reveal[data-placement="top"] > .hk-hover-reveal-extension {
58
+ left: 50%;
59
+ bottom: 100%;
60
+ transform: translate(-50%, 4px);
61
+ }
62
+ .hk-hover-reveal[data-placement="top"].is-revealed > .hk-hover-reveal-extension {
63
+ opacity: 1;
64
+ pointer-events: auto;
65
+ transform: translate(-50%, -4px);
66
+ }
@@ -0,0 +1,77 @@
1
+ import { defineComponent, h, onUnmounted, ref, watch, type PropType } from "vue";
2
+
3
+ import "./HkHoverRevealAction.scss";
4
+ import { scheduleCronAfter, type CronHandle } from "../runtime/cronBus";
5
+
6
+ export type HoverRevealPlacement = "right" | "left" | "top" | "bottom";
7
+
8
+ export default defineComponent({
9
+ name: "HkHoverRevealAction",
10
+ props: {
11
+ hideDelay: { type: Number, default: 140 },
12
+ as: { type: String, default: "span" },
13
+ placement: { type: String as PropType<HoverRevealPlacement>, default: "right" },
14
+ forceRevealed: { type: Boolean, default: false },
15
+ },
16
+ emits: {
17
+ revealChange: (_revealed: boolean) => true,
18
+ },
19
+ setup(props, { emit, slots }) {
20
+ const revealed = ref(false);
21
+ let hideTimer: CronHandle | null = null;
22
+
23
+ function clearHideTimer() {
24
+ if (hideTimer) {
25
+ hideTimer.disconnect();
26
+ hideTimer = null;
27
+ }
28
+ }
29
+
30
+ function reveal() {
31
+ clearHideTimer();
32
+ if (!revealed.value) {
33
+ revealed.value = true;
34
+ emit("revealChange", true);
35
+ }
36
+ }
37
+
38
+ function scheduleHide() {
39
+ if (props.forceRevealed) return;
40
+ clearHideTimer();
41
+ hideTimer = scheduleCronAfter(() => {
42
+ if (revealed.value) {
43
+ revealed.value = false;
44
+ emit("revealChange", false);
45
+ }
46
+ }, props.hideDelay);
47
+ }
48
+
49
+ watch(
50
+ () => props.forceRevealed,
51
+ (forced) => {
52
+ if (forced) reveal();
53
+ else scheduleHide();
54
+ },
55
+ );
56
+
57
+ onUnmounted(clearHideTimer);
58
+
59
+ return () => {
60
+ return h(
61
+ props.as,
62
+ {
63
+ class: ["hk-hover-reveal", revealed.value && "is-revealed"],
64
+ "data-placement": props.placement,
65
+ onMouseenter: reveal,
66
+ onMouseleave: scheduleHide,
67
+ },
68
+ [
69
+ h("span", { class: "hk-hover-reveal-main" }, slots.default?.()),
70
+ slots.extension
71
+ ? h("span", { class: "hk-hover-reveal-extension" }, slots.extension({ revealed: revealed.value }))
72
+ : null,
73
+ ].filter(Boolean),
74
+ );
75
+ };
76
+ },
77
+ });
@@ -0,0 +1,43 @@
1
+ import { defineComponent, type PropType } from "vue";
2
+ import * as LucideIcons from "lucide-vue-next";
3
+ import "../../../components/src/styles/components/icon.scss";
4
+
5
+ function pascalCase(str: string): string {
6
+ const camel = str.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
7
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
8
+ }
9
+
10
+ const icons = LucideIcons as Record<string, any>;
11
+
12
+ export default defineComponent({
13
+ name: "HkIcon",
14
+ props: {
15
+ name: { type: String, required: true },
16
+ size: { type: Number as PropType<16 | 20 | 24 | 32 | 40 | 48 | 64>, default: 20 },
17
+ color: { type: String as PropType<"primary" | "secondary" | "accent" | "success" | "warning" | "danger" | "muted"> },
18
+ },
19
+ setup(props) {
20
+ return () => {
21
+ const cls = [
22
+ "hk-icon",
23
+ `hk-icon-${props.size}`,
24
+ props.color ? `hk-icon-${props.color}` : "",
25
+ ];
26
+
27
+ const pName = pascalCase(props.name);
28
+ const IconComp = icons[pName] || icons[props.name];
29
+
30
+ if (IconComp) {
31
+ return <span class={cls}><IconComp /></span>;
32
+ }
33
+
34
+ return (
35
+ <span class={cls}>
36
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
37
+ <circle cx="12" cy="12" r="10" />
38
+ </svg>
39
+ </span>
40
+ );
41
+ };
42
+ },
43
+ });
@@ -0,0 +1,42 @@
1
+ import { computed, defineComponent, type PropType } from "vue";
2
+ import "../../../components/src/styles/components/icon_button.scss";
3
+ import "../../../components/src/styles/components/icon-button-vars.scss";
4
+
5
+ export default defineComponent({
6
+ name: "HkIconButton",
7
+ inheritAttrs: false,
8
+ props: {
9
+ icon: { type: String, default: "" },
10
+ variant: { type: String as PropType<"ghost" | "primary" | "secondary" | "danger" | "success">, default: "ghost" },
11
+ size: { type: Number as PropType<16 | 24 | 32 | 36 | 40>, default: 32 },
12
+ disabled: { type: Boolean, default: false },
13
+ },
14
+ emits: {
15
+ click: (_e: MouseEvent) => true,
16
+ },
17
+ setup(props, { emit, slots }) {
18
+ const cls = computed(() => [
19
+ "hk-icon-button",
20
+ `hk-icon-button-${props.size}`,
21
+ `hk-icon-button-${props.variant}`,
22
+ ]);
23
+
24
+ return () => (
25
+ <button
26
+ class={cls.value}
27
+ disabled={props.disabled}
28
+ onClick={(e: MouseEvent) => emit("click", e)}
29
+ >
30
+ <span class="hk-icon-button-icon">
31
+ {slots.icon ? (
32
+ slots.icon()
33
+ ) : (
34
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
35
+ <circle cx="12" cy="12" r="10" />
36
+ </svg>
37
+ )}
38
+ </span>
39
+ </button>
40
+ );
41
+ },
42
+ });
@@ -0,0 +1,33 @@
1
+ .hk-image-viewer {
2
+ position: relative;
3
+ width: 100%;
4
+ height: 60vh;
5
+ min-height: 18rem;
6
+ overflow: hidden;
7
+ background: rgb(0 0 0 / 45%);
8
+ border-radius: var(--radius-sm);
9
+ touch-action: none;
10
+ user-select: none;
11
+ }
12
+
13
+ .hk-image-viewer-img {
14
+ position: absolute;
15
+ top: 0;
16
+ left: 0;
17
+ max-width: none;
18
+ max-height: none;
19
+ will-change: transform;
20
+ transition: transform 0.12s ease-out;
21
+
22
+ &.is-loading {
23
+ opacity: 0;
24
+ }
25
+ }
26
+
27
+ .hk-image-viewer-loading {
28
+ position: absolute;
29
+ inset: 0;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ }
@@ -0,0 +1,250 @@
1
+ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } from "vue";
2
+
3
+ import HSpinner from "./HkSpinner";
4
+ import HMinimap from "./HkMinimap";
5
+ import "./HkImageViewer.scss";
6
+
7
+ /**
8
+ * Zoomable / pannable image viewer.
9
+ *
10
+ * The image is laid out at its natural pixel size and transformed with
11
+ * `translate(panX, panY) scale(zoom)` (transform-origin 0 0). That keeps it
12
+ * compatible with HMinimap's viewport-rect math, which assumes exactly this
13
+ * transform model. Default state is fit-to-container + centred; wheel and
14
+ * double-click zoom (cursor-anchored), drag pans. The minimap reuses the
15
+ * generic HMinimap (rendered with the image as its background).
16
+ */
17
+ export default defineComponent({
18
+ name: "HkImageViewer",
19
+ props: {
20
+ src: { type: String, required: true },
21
+ alt: { type: String, default: "" },
22
+ },
23
+ setup(props) {
24
+ const containerRef = ref<HTMLElement | null>(null);
25
+ const imgRef = ref<HTMLImageElement | null>(null);
26
+
27
+ const zoom = ref(1);
28
+ const panX = ref(0);
29
+ const panY = ref(0);
30
+ const fit = ref(1);
31
+ const imgW = ref(0);
32
+ const imgH = ref(0);
33
+ const loaded = ref(false);
34
+
35
+ const dragging = ref(false);
36
+ const lastPt = ref({ x: 0, y: 0 });
37
+
38
+ const MAX_ZOOM = 8;
39
+
40
+ const isZoomed = computed(() => loaded.value && zoom.value > fit.value + 0.001);
41
+ const canZoomIn = computed(() => zoom.value < MAX_ZOOM);
42
+ const canZoomOut = computed(() => zoom.value > fit.value + 0.001);
43
+
44
+ function dims() {
45
+ const c = containerRef.value;
46
+ return c ? { cw: c.clientWidth, ch: c.clientHeight } : { cw: 0, ch: 0 };
47
+ }
48
+
49
+ function recomputeFit() {
50
+ const { cw, ch } = dims();
51
+ if (!imgW.value || !imgH.value || !cw || !ch) return;
52
+ fit.value = Math.min(1, cw / imgW.value, ch / imgH.value);
53
+ }
54
+
55
+ function center() {
56
+ const { cw, ch } = dims();
57
+ const dw = imgW.value * zoom.value;
58
+ const dh = imgH.value * zoom.value;
59
+ panX.value = (cw - dw) / 2;
60
+ panY.value = (ch - dh) / 2;
61
+ }
62
+
63
+ function clampPan() {
64
+ const { cw, ch } = dims();
65
+ const dw = imgW.value * zoom.value;
66
+ const dh = imgH.value * zoom.value;
67
+ if (dw <= cw) panX.value = (cw - dw) / 2;
68
+ else panX.value = Math.max(cw - dw, Math.min(0, panX.value));
69
+ if (dh <= ch) panY.value = (ch - dh) / 2;
70
+ else panY.value = Math.max(ch - dh, Math.min(0, panY.value));
71
+ }
72
+
73
+ function setZoom(next: number, cx?: number, cy?: number) {
74
+ const { cw, ch } = dims();
75
+ const target = Math.max(fit.value, Math.min(MAX_ZOOM, next));
76
+ if (target === zoom.value) return;
77
+ const anchorX = cx ?? cw / 2;
78
+ const anchorY = cy ?? ch / 2;
79
+ const wx = (anchorX - panX.value) / zoom.value;
80
+ const wy = (anchorY - panY.value) / zoom.value;
81
+ zoom.value = target;
82
+ panX.value = anchorX - wx * target;
83
+ panY.value = anchorY - wy * target;
84
+ clampPan();
85
+ }
86
+
87
+ function resetView() {
88
+ recomputeFit();
89
+ zoom.value = fit.value;
90
+ center();
91
+ }
92
+
93
+ function onImgLoad() {
94
+ const img = imgRef.value;
95
+ if (!img) return;
96
+ imgW.value = img.naturalWidth;
97
+ imgH.value = img.naturalHeight;
98
+ loaded.value = true;
99
+ resetView();
100
+ }
101
+
102
+ function onWheel(e: WheelEvent) {
103
+ if (!loaded.value) return;
104
+ e.preventDefault();
105
+ const el = containerRef.value;
106
+ if (!el) return;
107
+ const rect = el.getBoundingClientRect();
108
+ const cx = e.clientX - rect.left;
109
+ const cy = e.clientY - rect.top;
110
+ const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
111
+ setZoom(zoom.value * factor, cx, cy);
112
+ }
113
+
114
+ function onPointerDown(e: PointerEvent) {
115
+ if (!loaded.value) return;
116
+ dragging.value = true;
117
+ lastPt.value = { x: e.clientX, y: e.clientY };
118
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
119
+ }
120
+
121
+ function onPointerMove(e: PointerEvent) {
122
+ if (!dragging.value) return;
123
+ const dx = e.clientX - lastPt.value.x;
124
+ const dy = e.clientY - lastPt.value.y;
125
+ lastPt.value = { x: e.clientX, y: e.clientY };
126
+ panX.value += dx;
127
+ panY.value += dy;
128
+ clampPan();
129
+ }
130
+
131
+ function onPointerUp() {
132
+ dragging.value = false;
133
+ }
134
+
135
+ function onDblClick(e: MouseEvent) {
136
+ if (!loaded.value) return;
137
+ const el = containerRef.value;
138
+ if (!el) return;
139
+ const rect = el.getBoundingClientRect();
140
+ const cx = e.clientX - rect.left;
141
+ const cy = e.clientY - rect.top;
142
+ setZoom(isZoomed.value ? fit.value : Math.min(MAX_ZOOM, fit.value * 3), cx, cy);
143
+ }
144
+
145
+ function onPanDelta(dx: number, dy: number) {
146
+ panX.value += dx;
147
+ panY.value += dy;
148
+ clampPan();
149
+ }
150
+
151
+ let ro: ResizeObserver | null = null;
152
+
153
+ onMounted(() => {
154
+ const c = containerRef.value;
155
+ if (!c) return;
156
+ ro = new ResizeObserver(() => {
157
+ const wasFit = !isZoomed.value;
158
+ recomputeFit();
159
+ if (wasFit) {
160
+ zoom.value = fit.value;
161
+ center();
162
+ } else {
163
+ clampPan();
164
+ }
165
+ });
166
+ ro.observe(c);
167
+ c.addEventListener("wheel", onWheel, { passive: false });
168
+ c.addEventListener("pointerdown", onPointerDown);
169
+ c.addEventListener("pointermove", onPointerMove);
170
+ c.addEventListener("pointerup", onPointerUp);
171
+ c.addEventListener("pointercancel", onPointerUp);
172
+ c.addEventListener("dblclick", onDblClick);
173
+ });
174
+
175
+ onBeforeUnmount(() => {
176
+ const c = containerRef.value;
177
+ if (!c) return;
178
+ ro?.disconnect();
179
+ ro = null;
180
+ c.removeEventListener("wheel", onWheel);
181
+ c.removeEventListener("pointerdown", onPointerDown);
182
+ c.removeEventListener("pointermove", onPointerMove);
183
+ c.removeEventListener("pointerup", onPointerUp);
184
+ c.removeEventListener("pointercancel", onPointerUp);
185
+ c.removeEventListener("dblclick", onDblClick);
186
+ });
187
+
188
+ watch(
189
+ () => props.src,
190
+ () => {
191
+ loaded.value = false;
192
+ imgW.value = 0;
193
+ imgH.value = 0;
194
+ zoom.value = 1;
195
+ fit.value = 1;
196
+ panX.value = 0;
197
+ panY.value = 0;
198
+ },
199
+ );
200
+
201
+ const imgStyle = computed(() => ({
202
+ transform: `translate(${panX.value}px, ${panY.value}px) scale(${zoom.value})`,
203
+ transformOrigin: "0 0",
204
+ cursor: isZoomed.value ? (dragging.value ? "grabbing" : "grab") : "zoom-in",
205
+ }));
206
+
207
+ const contentBounds = computed(() => ({ x: 0, y: 0, w: imgW.value, h: imgH.value }));
208
+ const zoomPercent = computed(() => Math.round(zoom.value * 100));
209
+
210
+ return () => (
211
+ <div ref={containerRef} class="hk-image-viewer">
212
+ <img
213
+ ref={imgRef}
214
+ src={props.src}
215
+ alt={props.alt}
216
+ class={["hk-image-viewer-img", !loaded.value && "is-loading"].filter(Boolean).join(" ")}
217
+ style={imgStyle.value}
218
+ draggable={false}
219
+ onLoad={onImgLoad}
220
+ />
221
+ {!loaded.value && (
222
+ <div class="hk-image-viewer-loading">
223
+ <HSpinner size="md" />
224
+ </div>
225
+ )}
226
+
227
+ {loaded.value && isZoomed.value && (
228
+ <HMinimap
229
+ imageSrc={props.src}
230
+ imageBounds={contentBounds.value}
231
+ zoom={zoom.value}
232
+ panX={panX.value}
233
+ panY={panY.value}
234
+ viewportWidth={dims().cw}
235
+ viewportHeight={dims().ch}
236
+ contentBounds={contentBounds.value}
237
+ zoomPercent={zoomPercent.value}
238
+ canZoomIn={canZoomIn.value}
239
+ canZoomOut={canZoomOut.value}
240
+ showReset
241
+ onZoomIn={() => setZoom(zoom.value * 1.2)}
242
+ onZoomOut={() => setZoom(zoom.value / 1.2)}
243
+ onReset={resetView}
244
+ onPanDelta={onPanDelta}
245
+ />
246
+ )}
247
+ </div>
248
+ );
249
+ },
250
+ });