tekivex-ui 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +75 -14
- package/dist/index.d.ts +34 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11013 -4880
- package/dist/src/components/TkxAffix.d.ts +10 -0
- package/dist/src/components/TkxAffix.d.ts.map +1 -0
- package/dist/src/components/TkxAnchor.d.ts +14 -0
- package/dist/src/components/TkxAnchor.d.ts.map +1 -0
- package/dist/src/components/TkxCascader.d.ts +16 -0
- package/dist/src/components/TkxCascader.d.ts.map +1 -0
- package/dist/src/components/TkxConfigProvider.d.ts +100 -0
- package/dist/src/components/TkxConfigProvider.d.ts.map +1 -0
- package/dist/src/components/TkxDataGrid.d.ts +5 -1
- package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
- package/dist/src/components/TkxEmpty.d.ts +870 -0
- package/dist/src/components/TkxEmpty.d.ts.map +1 -0
- package/dist/src/components/TkxForm.d.ts +54 -0
- package/dist/src/components/TkxForm.d.ts.map +1 -0
- package/dist/src/components/TkxLayout.d.ts +87 -0
- package/dist/src/components/TkxLayout.d.ts.map +1 -0
- package/dist/src/components/TkxList.d.ts +24 -0
- package/dist/src/components/TkxList.d.ts.map +1 -0
- package/dist/src/components/TkxMentions.d.ts +15 -0
- package/dist/src/components/TkxMentions.d.ts.map +1 -0
- package/dist/src/components/TkxQRCode.d.ts +11 -0
- package/dist/src/components/TkxQRCode.d.ts.map +1 -0
- package/dist/src/components/TkxResult.d.ts +11 -0
- package/dist/src/components/TkxResult.d.ts.map +1 -0
- package/dist/src/components/TkxSegmented.d.ts +16 -0
- package/dist/src/components/TkxSegmented.d.ts.map +1 -0
- package/dist/src/components/TkxSelect.d.ts +5 -1
- package/dist/src/components/TkxSelect.d.ts.map +1 -1
- package/dist/src/components/TkxSpin.d.ts +15 -0
- package/dist/src/components/TkxSpin.d.ts.map +1 -0
- package/dist/src/components/TkxStatistic.d.ts +1746 -0
- package/dist/src/components/TkxStatistic.d.ts.map +1 -0
- package/dist/src/components/TkxTour.d.ts +15 -0
- package/dist/src/components/TkxTour.d.ts.map +1 -0
- package/dist/src/components/TkxTypography.d.ts +2614 -0
- package/dist/src/components/TkxTypography.d.ts.map +1 -0
- package/dist/src/components/TkxWatermark.d.ts +12 -0
- package/dist/src/components/TkxWatermark.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +17 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/engine/tkx.d.ts +38 -0
- package/dist/src/engine/tkx.d.ts.map +1 -1
- package/dist/src/themes/index.d.ts +108 -0
- package/dist/src/themes/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/TkxAffix.tsx +138 -0
- package/src/components/TkxAnchor.tsx +193 -0
- package/src/components/TkxCascader.tsx +276 -0
- package/src/components/TkxConfigProvider.tsx +458 -0
- package/src/components/TkxDataGrid.tsx +65 -2
- package/src/components/TkxEmpty.tsx +219 -0
- package/src/components/TkxForm.tsx +607 -0
- package/src/components/TkxLayout.tsx +647 -0
- package/src/components/TkxList.tsx +242 -0
- package/src/components/TkxMentions.tsx +210 -0
- package/src/components/TkxQRCode.tsx +174 -0
- package/src/components/TkxResult.tsx +128 -0
- package/src/components/TkxSegmented.tsx +169 -0
- package/src/components/TkxSelect.tsx +209 -6
- package/src/components/TkxSpin.tsx +261 -0
- package/src/components/TkxStatistic.tsx +261 -0
- package/src/components/TkxTour.tsx +239 -0
- package/src/components/TkxTypography.tsx +263 -0
- package/src/components/TkxWatermark.tsx +143 -0
- package/src/components/index.ts +17 -0
- package/src/engine/tkx.ts +62 -0
- package/src/themes/index.ts +149 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { useTheme } from '../themes';
|
|
4
|
+
import { sanitizeString } from '../engine/security';
|
|
5
|
+
import { useReducedMotion } from '../hooks';
|
|
6
|
+
import { tkx } from '../engine/tkx';
|
|
7
|
+
|
|
8
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface TourStep {
|
|
11
|
+
target: string;
|
|
12
|
+
title: string;
|
|
13
|
+
description: string;
|
|
14
|
+
placement?: 'top' | 'bottom' | 'left' | 'right';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TkxTourProps {
|
|
18
|
+
steps: TourStep[];
|
|
19
|
+
isOpen?: boolean;
|
|
20
|
+
onClose?: () => void;
|
|
21
|
+
current?: number;
|
|
22
|
+
onChange?: (step: number) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function getRect(selector: string): DOMRect | null {
|
|
28
|
+
const el = document.querySelector(selector);
|
|
29
|
+
return el ? el.getBoundingClientRect() : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PopoverPos {
|
|
33
|
+
top: number;
|
|
34
|
+
left: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function computePosition(
|
|
38
|
+
rect: DOMRect,
|
|
39
|
+
placement: string,
|
|
40
|
+
tipW: number,
|
|
41
|
+
tipH: number,
|
|
42
|
+
): PopoverPos {
|
|
43
|
+
const gap = 12;
|
|
44
|
+
switch (placement) {
|
|
45
|
+
case 'top':
|
|
46
|
+
return { top: rect.top - tipH - gap + window.scrollY, left: rect.left + rect.width / 2 - tipW / 2 + window.scrollX };
|
|
47
|
+
case 'bottom':
|
|
48
|
+
return { top: rect.bottom + gap + window.scrollY, left: rect.left + rect.width / 2 - tipW / 2 + window.scrollX };
|
|
49
|
+
case 'left':
|
|
50
|
+
return { top: rect.top + rect.height / 2 - tipH / 2 + window.scrollY, left: rect.left - tipW - gap + window.scrollX };
|
|
51
|
+
case 'right':
|
|
52
|
+
return { top: rect.top + rect.height / 2 - tipH / 2 + window.scrollY, left: rect.right + gap + window.scrollX };
|
|
53
|
+
default:
|
|
54
|
+
return { top: rect.bottom + gap + window.scrollY, left: rect.left + window.scrollX };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Component ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export function TkxTour({
|
|
61
|
+
steps,
|
|
62
|
+
isOpen = false,
|
|
63
|
+
onClose,
|
|
64
|
+
current: controlledCurrent,
|
|
65
|
+
onChange,
|
|
66
|
+
}: TkxTourProps) {
|
|
67
|
+
const theme = useTheme();
|
|
68
|
+
const reducedMotion = useReducedMotion();
|
|
69
|
+
const [internal, setInternal] = useState(0);
|
|
70
|
+
const current = controlledCurrent ?? internal;
|
|
71
|
+
const tipRef = useRef<HTMLDivElement>(null);
|
|
72
|
+
const [pos, setPos] = useState<PopoverPos>({ top: 0, left: 0 });
|
|
73
|
+
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
|
74
|
+
|
|
75
|
+
const step = steps[current];
|
|
76
|
+
|
|
77
|
+
const updatePosition = useCallback(() => {
|
|
78
|
+
if (!step) return;
|
|
79
|
+
const rect = getRect(step.target);
|
|
80
|
+
setTargetRect(rect);
|
|
81
|
+
if (rect && tipRef.current) {
|
|
82
|
+
const tipW = tipRef.current.offsetWidth;
|
|
83
|
+
const tipH = tipRef.current.offsetHeight;
|
|
84
|
+
setPos(computePosition(rect, step.placement ?? 'bottom', tipW, tipH));
|
|
85
|
+
}
|
|
86
|
+
}, [step]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!isOpen) return;
|
|
90
|
+
updatePosition();
|
|
91
|
+
window.addEventListener('resize', updatePosition);
|
|
92
|
+
window.addEventListener('scroll', updatePosition, true);
|
|
93
|
+
return () => {
|
|
94
|
+
window.removeEventListener('resize', updatePosition);
|
|
95
|
+
window.removeEventListener('scroll', updatePosition, true);
|
|
96
|
+
};
|
|
97
|
+
}, [isOpen, current, updatePosition]);
|
|
98
|
+
|
|
99
|
+
// Focus trap: refocus popover on step change
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (isOpen) tipRef.current?.focus();
|
|
102
|
+
}, [isOpen, current]);
|
|
103
|
+
|
|
104
|
+
const goTo = useCallback(
|
|
105
|
+
(idx: number) => {
|
|
106
|
+
setInternal(idx);
|
|
107
|
+
onChange?.(idx);
|
|
108
|
+
},
|
|
109
|
+
[onChange],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const handleNext = useCallback(() => {
|
|
113
|
+
if (current < steps.length - 1) goTo(current + 1);
|
|
114
|
+
else onClose?.();
|
|
115
|
+
}, [current, steps.length, goTo, onClose]);
|
|
116
|
+
|
|
117
|
+
const handlePrev = useCallback(() => {
|
|
118
|
+
if (current > 0) goTo(current - 1);
|
|
119
|
+
}, [current, goTo]);
|
|
120
|
+
|
|
121
|
+
if (!isOpen || !step) return null;
|
|
122
|
+
|
|
123
|
+
const pad = 6;
|
|
124
|
+
const safeTitle = sanitizeString(step.title);
|
|
125
|
+
const safeDesc = sanitizeString(step.description);
|
|
126
|
+
const TIP_W = 300;
|
|
127
|
+
|
|
128
|
+
const overlay = (
|
|
129
|
+
<div aria-hidden="true">
|
|
130
|
+
{/* Dimming overlay with spotlight cutout via CSS clip-path */}
|
|
131
|
+
<div
|
|
132
|
+
style={{
|
|
133
|
+
position: 'fixed',
|
|
134
|
+
inset: 0,
|
|
135
|
+
zIndex: 9998,
|
|
136
|
+
backgroundColor: 'rgba(0,0,0,0.55)',
|
|
137
|
+
clipPath: targetRect
|
|
138
|
+
? `polygon(0% 0%, 0% 100%, ${targetRect.left - pad}px 100%, ${targetRect.left - pad}px ${targetRect.top - pad}px, ${targetRect.right + pad}px ${targetRect.top - pad}px, ${targetRect.right + pad}px ${targetRect.bottom + pad}px, ${targetRect.left - pad}px ${targetRect.bottom + pad}px, ${targetRect.left - pad}px 100%, 100% 100%, 100% 0%)`
|
|
139
|
+
: undefined,
|
|
140
|
+
transition: reducedMotion ? 'none' : 'clip-path 0.25s ease',
|
|
141
|
+
}}
|
|
142
|
+
onClick={onClose}
|
|
143
|
+
/>
|
|
144
|
+
|
|
145
|
+
{/* Popover */}
|
|
146
|
+
<div
|
|
147
|
+
ref={tipRef}
|
|
148
|
+
role="dialog"
|
|
149
|
+
aria-modal="true"
|
|
150
|
+
aria-label={`Tour step ${current + 1} of ${steps.length}`}
|
|
151
|
+
tabIndex={-1}
|
|
152
|
+
style={{
|
|
153
|
+
position: 'absolute',
|
|
154
|
+
zIndex: 9999,
|
|
155
|
+
top: pos.top,
|
|
156
|
+
left: pos.left,
|
|
157
|
+
width: TIP_W,
|
|
158
|
+
backgroundColor: theme.surface,
|
|
159
|
+
border: `1px solid ${theme.border}`,
|
|
160
|
+
borderRadius: 10,
|
|
161
|
+
padding: 20,
|
|
162
|
+
boxShadow: `0 8px 24px ${theme.bg}80`,
|
|
163
|
+
animation: reducedMotion ? 'none' : 'tkxFadeIn 0.2s ease',
|
|
164
|
+
fontFamily: 'inherit',
|
|
165
|
+
}}
|
|
166
|
+
onKeyDown={(e) => {
|
|
167
|
+
if (e.key === 'Escape') onClose?.();
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<h3
|
|
171
|
+
className={tkx('m-0 mb-2 text-base font-semibold')}
|
|
172
|
+
style={{ color: theme.text }}
|
|
173
|
+
>
|
|
174
|
+
{safeTitle}
|
|
175
|
+
</h3>
|
|
176
|
+
<p
|
|
177
|
+
className={tkx('m-0 mb-4 text-sm leading-relaxed')}
|
|
178
|
+
style={{ color: theme.textMuted }}
|
|
179
|
+
>
|
|
180
|
+
{safeDesc}
|
|
181
|
+
</p>
|
|
182
|
+
|
|
183
|
+
{/* Step dots */}
|
|
184
|
+
<div className={tkx('flex items-center gap-1 mb-4')} aria-label="Tour progress">
|
|
185
|
+
{steps.map((_, i) => (
|
|
186
|
+
<span
|
|
187
|
+
key={i}
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
style={{
|
|
190
|
+
width: 8,
|
|
191
|
+
height: 8,
|
|
192
|
+
borderRadius: '50%',
|
|
193
|
+
backgroundColor: i === current ? theme.primary : theme.border,
|
|
194
|
+
transition: reducedMotion ? 'none' : 'background-color 0.2s',
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Navigation */}
|
|
201
|
+
<div className={tkx('flex items-center justify-between gap-2')}>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
aria-label="Skip tour"
|
|
205
|
+
onClick={onClose}
|
|
206
|
+
className={tkx('border-0 bg-transparent cursor-pointer text-sm px-2 py-1')}
|
|
207
|
+
style={{ color: theme.textMuted }}
|
|
208
|
+
>
|
|
209
|
+
Skip
|
|
210
|
+
</button>
|
|
211
|
+
<div className={tkx('flex gap-2')}>
|
|
212
|
+
{current > 0 && (
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
aria-label="Previous step"
|
|
216
|
+
onClick={handlePrev}
|
|
217
|
+
className={tkx('rounded-md border px-3 py-1 text-sm cursor-pointer bg-transparent')}
|
|
218
|
+
style={{ borderColor: theme.border, color: theme.text }}
|
|
219
|
+
>
|
|
220
|
+
Prev
|
|
221
|
+
</button>
|
|
222
|
+
)}
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
aria-label={current === steps.length - 1 ? 'Finish tour' : 'Next step'}
|
|
226
|
+
onClick={handleNext}
|
|
227
|
+
className={tkx('rounded-md border-0 px-4 py-1 text-sm cursor-pointer font-medium')}
|
|
228
|
+
style={{ backgroundColor: theme.primary, color: '#fff' }}
|
|
229
|
+
>
|
|
230
|
+
{current === steps.length - 1 ? 'Finish' : 'Next'}
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return createPortal(overlay, document.body);
|
|
239
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
createElement,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { useTheme } from '../themes';
|
|
9
|
+
import { sanitizeString } from '../engine/security';
|
|
10
|
+
|
|
11
|
+
// ── Interfaces ──────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export type TypographyType = 'default' | 'secondary' | 'success' | 'warning' | 'danger';
|
|
14
|
+
|
|
15
|
+
export interface TkxTitleProps {
|
|
16
|
+
level?: 1 | 2 | 3 | 4 | 5;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
copyable?: boolean;
|
|
19
|
+
type?: TypographyType;
|
|
20
|
+
style?: CSSProperties;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TkxTextProps {
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
type?: TypographyType;
|
|
26
|
+
strong?: boolean;
|
|
27
|
+
italic?: boolean;
|
|
28
|
+
underline?: boolean;
|
|
29
|
+
delete?: boolean;
|
|
30
|
+
code?: boolean;
|
|
31
|
+
mark?: boolean;
|
|
32
|
+
copyable?: boolean;
|
|
33
|
+
style?: CSSProperties;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TkxParagraphProps {
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
type?: 'default' | 'secondary';
|
|
39
|
+
copyable?: boolean;
|
|
40
|
+
ellipsis?: boolean | { rows?: number };
|
|
41
|
+
style?: CSSProperties;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function getTextContent(node: ReactNode): string {
|
|
47
|
+
if (typeof node === 'string') return node;
|
|
48
|
+
if (typeof node === 'number') return String(node);
|
|
49
|
+
if (Array.isArray(node)) return node.map(getTextContent).join('');
|
|
50
|
+
if (node && typeof node === 'object' && 'props' in node) {
|
|
51
|
+
return getTextContent((node as { props: { children?: ReactNode } }).props.children);
|
|
52
|
+
}
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── CopyButton ──────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function CopyButton({ text }: { text: string }) {
|
|
59
|
+
const theme = useTheme();
|
|
60
|
+
const [copied, setCopied] = useState(false);
|
|
61
|
+
|
|
62
|
+
const handleCopy = useCallback(() => {
|
|
63
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
64
|
+
setCopied(true);
|
|
65
|
+
setTimeout(() => setCopied(false), 2000);
|
|
66
|
+
});
|
|
67
|
+
}, [text]);
|
|
68
|
+
|
|
69
|
+
return createElement(
|
|
70
|
+
'button',
|
|
71
|
+
{
|
|
72
|
+
type: 'button' as const,
|
|
73
|
+
onClick: handleCopy,
|
|
74
|
+
'aria-label': copied ? 'Copied' : 'Copy to clipboard',
|
|
75
|
+
style: {
|
|
76
|
+
display: 'inline-flex',
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
marginLeft: '4px',
|
|
79
|
+
padding: '2px 6px',
|
|
80
|
+
border: 'none',
|
|
81
|
+
background: 'transparent',
|
|
82
|
+
color: copied ? theme.success : theme.textMuted,
|
|
83
|
+
cursor: 'pointer',
|
|
84
|
+
fontSize: '0.8em',
|
|
85
|
+
borderRadius: '4px',
|
|
86
|
+
transition: 'color 0.2s',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
copied ? 'Copied!' : '\u2398',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Color Map ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function useTypeColor(type: TypographyType = 'default') {
|
|
96
|
+
const theme = useTheme();
|
|
97
|
+
const map: Record<TypographyType, string> = {
|
|
98
|
+
default: theme.text,
|
|
99
|
+
secondary: theme.textMuted,
|
|
100
|
+
success: theme.success,
|
|
101
|
+
warning: theme.warning,
|
|
102
|
+
danger: theme.danger,
|
|
103
|
+
};
|
|
104
|
+
return map[type];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Title Sizes ─────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const TITLE_SIZES: Record<number, { fontSize: string; lineHeight: string; fontWeight: number }> = {
|
|
110
|
+
1: { fontSize: '2.25rem', lineHeight: '1.2', fontWeight: 700 },
|
|
111
|
+
2: { fontSize: '1.875rem', lineHeight: '1.25', fontWeight: 700 },
|
|
112
|
+
3: { fontSize: '1.5rem', lineHeight: '1.3', fontWeight: 600 },
|
|
113
|
+
4: { fontSize: '1.25rem', lineHeight: '1.35', fontWeight: 600 },
|
|
114
|
+
5: { fontSize: '1rem', lineHeight: '1.4', fontWeight: 600 },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ── TkxTitle ────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export function TkxTitle({
|
|
120
|
+
level = 1,
|
|
121
|
+
children,
|
|
122
|
+
copyable = false,
|
|
123
|
+
type = 'default',
|
|
124
|
+
style,
|
|
125
|
+
}: TkxTitleProps) {
|
|
126
|
+
const color = useTypeColor(type);
|
|
127
|
+
const theme = useTheme();
|
|
128
|
+
const sizeConfig = TITLE_SIZES[level];
|
|
129
|
+
const tag = `h${level}` as keyof JSX.IntrinsicElements;
|
|
130
|
+
|
|
131
|
+
const safeChildren = typeof children === 'string' ? sanitizeString(children) : children;
|
|
132
|
+
const textContent = getTextContent(children);
|
|
133
|
+
|
|
134
|
+
return createElement(
|
|
135
|
+
tag,
|
|
136
|
+
{
|
|
137
|
+
style: {
|
|
138
|
+
color,
|
|
139
|
+
fontSize: sizeConfig.fontSize,
|
|
140
|
+
lineHeight: sizeConfig.lineHeight,
|
|
141
|
+
fontWeight: sizeConfig.fontWeight,
|
|
142
|
+
margin: '0 0 0.5em 0',
|
|
143
|
+
fontFamily: 'inherit',
|
|
144
|
+
...style,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
safeChildren,
|
|
148
|
+
copyable && createElement(CopyButton, { text: textContent }),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── TkxText ─────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export function TkxText({
|
|
155
|
+
children,
|
|
156
|
+
type = 'default',
|
|
157
|
+
strong = false,
|
|
158
|
+
italic = false,
|
|
159
|
+
underline = false,
|
|
160
|
+
delete: strikethrough = false,
|
|
161
|
+
code = false,
|
|
162
|
+
mark = false,
|
|
163
|
+
copyable = false,
|
|
164
|
+
style,
|
|
165
|
+
}: TkxTextProps) {
|
|
166
|
+
const color = useTypeColor(type);
|
|
167
|
+
const theme = useTheme();
|
|
168
|
+
const textContent = getTextContent(children);
|
|
169
|
+
const safeChildren = typeof children === 'string' ? sanitizeString(children) : children;
|
|
170
|
+
|
|
171
|
+
const decorations: string[] = [];
|
|
172
|
+
if (underline) decorations.push('underline');
|
|
173
|
+
if (strikethrough) decorations.push('line-through');
|
|
174
|
+
|
|
175
|
+
let content: ReactNode = safeChildren;
|
|
176
|
+
|
|
177
|
+
if (code) {
|
|
178
|
+
content = createElement(
|
|
179
|
+
'code',
|
|
180
|
+
{
|
|
181
|
+
style: {
|
|
182
|
+
padding: '0.15em 0.4em',
|
|
183
|
+
fontSize: '0.875em',
|
|
184
|
+
backgroundColor: theme.surfaceAlt,
|
|
185
|
+
border: `1px solid ${theme.border}`,
|
|
186
|
+
borderRadius: '4px',
|
|
187
|
+
fontFamily: 'monospace',
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
content,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (mark) {
|
|
195
|
+
content = createElement(
|
|
196
|
+
'mark',
|
|
197
|
+
{
|
|
198
|
+
style: {
|
|
199
|
+
backgroundColor: `${theme.warning}33`,
|
|
200
|
+
color,
|
|
201
|
+
padding: '0 2px',
|
|
202
|
+
borderRadius: '2px',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
content,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return createElement(
|
|
210
|
+
'span',
|
|
211
|
+
{
|
|
212
|
+
style: {
|
|
213
|
+
color,
|
|
214
|
+
fontWeight: strong ? 600 : 'inherit',
|
|
215
|
+
fontStyle: italic ? 'italic' : 'normal',
|
|
216
|
+
textDecoration: decorations.length > 0 ? decorations.join(' ') : 'none',
|
|
217
|
+
...style,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
content,
|
|
221
|
+
copyable && createElement(CopyButton, { text: textContent }),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── TkxParagraph ────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
export function TkxParagraph({
|
|
228
|
+
children,
|
|
229
|
+
type = 'default',
|
|
230
|
+
copyable = false,
|
|
231
|
+
ellipsis = false,
|
|
232
|
+
style,
|
|
233
|
+
}: TkxParagraphProps) {
|
|
234
|
+
const theme = useTheme();
|
|
235
|
+
const color = type === 'secondary' ? theme.textMuted : theme.text;
|
|
236
|
+
const textContent = getTextContent(children);
|
|
237
|
+
const safeChildren = typeof children === 'string' ? sanitizeString(children) : children;
|
|
238
|
+
|
|
239
|
+
const ellipsisStyle: CSSProperties = {};
|
|
240
|
+
if (ellipsis) {
|
|
241
|
+
const rows = typeof ellipsis === 'object' ? (ellipsis.rows ?? 3) : 3;
|
|
242
|
+
ellipsisStyle.display = '-webkit-box';
|
|
243
|
+
ellipsisStyle.WebkitLineClamp = rows;
|
|
244
|
+
ellipsisStyle.WebkitBoxOrient = 'vertical';
|
|
245
|
+
ellipsisStyle.overflow = 'hidden';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return createElement(
|
|
249
|
+
'div',
|
|
250
|
+
{
|
|
251
|
+
style: {
|
|
252
|
+
color,
|
|
253
|
+
fontSize: '1rem',
|
|
254
|
+
lineHeight: '1.6',
|
|
255
|
+
margin: '0 0 1em 0',
|
|
256
|
+
...ellipsisStyle,
|
|
257
|
+
...style,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
safeChildren,
|
|
261
|
+
copyable && createElement(CopyButton, { text: textContent }),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { type ReactNode, useRef, useEffect, useMemo, useState, useCallback } from 'react';
|
|
2
|
+
import { useTheme } from '../themes';
|
|
3
|
+
import { sanitizeString } from '../engine/security';
|
|
4
|
+
import { useReducedMotion } from '../hooks';
|
|
5
|
+
import { tkx } from '../engine/tkx';
|
|
6
|
+
|
|
7
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface TkxWatermarkProps {
|
|
10
|
+
text: string | string[];
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
rotate?: number;
|
|
13
|
+
gap?: [number, number];
|
|
14
|
+
fontSize?: number;
|
|
15
|
+
color?: string;
|
|
16
|
+
zIndex?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Canvas Renderer ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function renderWatermarkPattern(
|
|
22
|
+
lines: string[],
|
|
23
|
+
rotate: number,
|
|
24
|
+
gap: [number, number],
|
|
25
|
+
fontSize: number,
|
|
26
|
+
fillColor: string,
|
|
27
|
+
): string {
|
|
28
|
+
const canvas = document.createElement('canvas');
|
|
29
|
+
const ctx = canvas.getContext('2d');
|
|
30
|
+
if (!ctx) return '';
|
|
31
|
+
|
|
32
|
+
const lineHeight = fontSize * 1.5;
|
|
33
|
+
const totalTextHeight = lines.length * lineHeight;
|
|
34
|
+
|
|
35
|
+
// Calculate canvas dimensions based on text length and gap
|
|
36
|
+
const maxTextWidth = Math.max(...lines.map((l) => l.length)) * fontSize * 0.6;
|
|
37
|
+
const canvasW = gap[0] + maxTextWidth;
|
|
38
|
+
const canvasH = gap[1] + totalTextHeight;
|
|
39
|
+
|
|
40
|
+
// High-DPI rendering
|
|
41
|
+
const dpr = 2;
|
|
42
|
+
canvas.width = canvasW * dpr;
|
|
43
|
+
canvas.height = canvasH * dpr;
|
|
44
|
+
ctx.scale(dpr, dpr);
|
|
45
|
+
|
|
46
|
+
// Move origin to center for rotation
|
|
47
|
+
ctx.translate(canvasW / 2, canvasH / 2);
|
|
48
|
+
ctx.rotate((rotate * Math.PI) / 180);
|
|
49
|
+
|
|
50
|
+
// Text styling
|
|
51
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
52
|
+
ctx.fillStyle = fillColor;
|
|
53
|
+
ctx.textAlign = 'center';
|
|
54
|
+
ctx.textBaseline = 'middle';
|
|
55
|
+
|
|
56
|
+
// Draw each line centered
|
|
57
|
+
lines.forEach((line, i) => {
|
|
58
|
+
const y = (i - (lines.length - 1) / 2) * lineHeight;
|
|
59
|
+
ctx.fillText(line, 0, y);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return canvas.toDataURL();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Component ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export function TkxWatermark({
|
|
68
|
+
text,
|
|
69
|
+
children,
|
|
70
|
+
rotate = -22,
|
|
71
|
+
gap = [100, 100],
|
|
72
|
+
fontSize = 14,
|
|
73
|
+
color,
|
|
74
|
+
zIndex = 10,
|
|
75
|
+
}: TkxWatermarkProps) {
|
|
76
|
+
const theme = useTheme();
|
|
77
|
+
const reducedMotion = useReducedMotion();
|
|
78
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
79
|
+
const [bgImage, setBgImage] = useState('');
|
|
80
|
+
|
|
81
|
+
// Sanitize text lines
|
|
82
|
+
const lines = useMemo(
|
|
83
|
+
() => (Array.isArray(text) ? text : [text]).map((t) => sanitizeString(t)),
|
|
84
|
+
[text],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const fillColor = color ?? `${theme.textMuted}22`;
|
|
88
|
+
|
|
89
|
+
// Memoize the render function
|
|
90
|
+
const generatePattern = useCallback(() => {
|
|
91
|
+
return renderWatermarkPattern(lines, rotate, gap, fontSize, fillColor);
|
|
92
|
+
}, [lines, rotate, gap, fontSize, fillColor]);
|
|
93
|
+
|
|
94
|
+
// Generate the watermark canvas pattern
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const dataUrl = generatePattern();
|
|
97
|
+
if (dataUrl) {
|
|
98
|
+
setBgImage(`url(${dataUrl})`);
|
|
99
|
+
}
|
|
100
|
+
}, [generatePattern]);
|
|
101
|
+
|
|
102
|
+
// Prevent tampering via MutationObserver
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const overlay = containerRef.current?.querySelector<HTMLElement>('[data-watermark]');
|
|
105
|
+
if (!overlay) return;
|
|
106
|
+
|
|
107
|
+
const observer = new MutationObserver(() => {
|
|
108
|
+
// Re-apply watermark if someone modifies it
|
|
109
|
+
const dataUrl = generatePattern();
|
|
110
|
+
if (dataUrl) {
|
|
111
|
+
overlay.style.backgroundImage = `url(${dataUrl})`;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
observer.observe(overlay, {
|
|
116
|
+
attributes: true,
|
|
117
|
+
attributeFilter: ['style'],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return () => observer.disconnect();
|
|
121
|
+
}, [generatePattern]);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
ref={containerRef}
|
|
126
|
+
className={tkx('relative')}
|
|
127
|
+
style={{ overflow: 'hidden' }}
|
|
128
|
+
>
|
|
129
|
+
{children}
|
|
130
|
+
<div
|
|
131
|
+
data-watermark
|
|
132
|
+
aria-hidden="true"
|
|
133
|
+
className={tkx('absolute inset-0 pointer-events-none')}
|
|
134
|
+
style={{
|
|
135
|
+
zIndex,
|
|
136
|
+
backgroundImage: bgImage,
|
|
137
|
+
backgroundRepeat: 'repeat',
|
|
138
|
+
animation: reducedMotion ? 'none' : 'tkxFadeIn 0.3s ease',
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -51,3 +51,20 @@ export * from './TkxSnackbar';
|
|
|
51
51
|
export * from './TkxDataGrid';
|
|
52
52
|
export * from './TkxMasonry';
|
|
53
53
|
export * from './TkxRichTextDisplay';
|
|
54
|
+
export * from './TkxForm';
|
|
55
|
+
export * from './TkxLayout';
|
|
56
|
+
export * from './TkxConfigProvider';
|
|
57
|
+
export * from './TkxTypography';
|
|
58
|
+
export * from './TkxSpin';
|
|
59
|
+
export * from './TkxEmpty';
|
|
60
|
+
export * from './TkxStatistic';
|
|
61
|
+
export * from './TkxSegmented';
|
|
62
|
+
export * from './TkxMentions';
|
|
63
|
+
export * from './TkxQRCode';
|
|
64
|
+
export * from './TkxResult';
|
|
65
|
+
export * from './TkxTour';
|
|
66
|
+
export * from './TkxWatermark';
|
|
67
|
+
export * from './TkxAffix';
|
|
68
|
+
export * from './TkxAnchor';
|
|
69
|
+
export * from './TkxCascader';
|
|
70
|
+
export * from './TkxList';
|