@tuturuuu/ui 0.28.0 → 0.28.1
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/CHANGELOG.md +7 -0
- package/package.json +2 -2
- package/src/components/ui/calendar.tsx +6 -6
- package/src/components/ui/date-time-picker-layout.ts +6 -0
- package/src/components/ui/date-time-picker.test.tsx +25 -0
- package/src/components/ui/date-time-picker.tsx +5 -5
- package/src/components/ui/text-editor/__tests__/image-extension-clipboard.test.ts +21 -0
- package/src/components/ui/text-editor/__tests__/markdown-paste-extension.test.ts +29 -1
- package/src/components/ui/text-editor/image-extension.ts +10 -14
- package/src/components/ui/text-editor/markdown-paste-extension.ts +25 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.28.1](https://github.com/tutur3u/platform/compare/ui-v0.28.0...ui-v0.28.1) (2026-08-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **tasks:** preserve rich paste and responsive dialogs ([0a6c5a3](https://github.com/tutur3u/platform/commit/0a6c5a362f51b475c2e8fe984eb3569a46a878ee))
|
|
9
|
+
|
|
3
10
|
## [0.28.0](https://github.com/tutur3u/platform/compare/ui-v0.27.0...ui-v0.28.0) (2026-08-15)
|
|
4
11
|
|
|
5
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"@tuturuuu/icons": "0.1.0",
|
|
94
94
|
"@tuturuuu/internal-api": "0.31.0",
|
|
95
95
|
"@tuturuuu/supabase": "0.5.0",
|
|
96
|
-
"@tuturuuu/utils": "0.25.
|
|
96
|
+
"@tuturuuu/utils": "0.25.2",
|
|
97
97
|
"@types/debug": "^4.1.13",
|
|
98
98
|
"browser-image-compression": "^2.0.2",
|
|
99
99
|
"class-variance-authority": "^0.7.1",
|
|
@@ -189,7 +189,7 @@ function Calendar({
|
|
|
189
189
|
};
|
|
190
190
|
|
|
191
191
|
return (
|
|
192
|
-
<div className="space-y-4">
|
|
192
|
+
<div className="min-w-0 space-y-4">
|
|
193
193
|
<div>
|
|
194
194
|
<div className="mb-4 flex items-center justify-between gap-2 border-b px-2 pb-4">
|
|
195
195
|
<button
|
|
@@ -318,21 +318,21 @@ function Calendar({
|
|
|
318
318
|
root: 'bg-transparent',
|
|
319
319
|
months: 'flex flex-col',
|
|
320
320
|
month:
|
|
321
|
-
'
|
|
321
|
+
'w-full min-w-0 max-w-[calc(100vw-4rem)] shrink-0 space-y-4 p-2 text-center font-semibold',
|
|
322
322
|
month_caption: 'hidden',
|
|
323
323
|
nav: 'hidden',
|
|
324
324
|
button_next: 'hidden',
|
|
325
325
|
button_previous: 'hidden',
|
|
326
326
|
month_grid: 'w-full border-collapse',
|
|
327
327
|
weeks: 'flex flex-col gap-1',
|
|
328
|
-
week: 'grid grid-cols-7 gap-
|
|
329
|
-
weekdays: 'grid w-full grid-cols-7 gap-
|
|
328
|
+
week: 'mt-2 grid grid-cols-7 gap-0.5 @[20rem]:gap-1',
|
|
329
|
+
weekdays: 'mb-2 grid w-full grid-cols-7 gap-0.5 @[20rem]:gap-1',
|
|
330
330
|
weekday:
|
|
331
331
|
'text-muted-foreground rounded-md font-normal text-[0.8rem] text-center',
|
|
332
|
-
day: '
|
|
332
|
+
day: 'relative min-w-0 p-0 text-center text-sm',
|
|
333
333
|
day_button: cn(
|
|
334
334
|
buttonVariants({ variant: 'ghost' }),
|
|
335
|
-
'h-
|
|
335
|
+
'aspect-square h-auto min-h-8 w-full rounded-md p-0 font-normal transition-colors duration-300',
|
|
336
336
|
'aria-selected:bg-primary aria-selected:text-primary-foreground',
|
|
337
337
|
'hover:bg-accent/50 hover:text-accent-foreground',
|
|
338
338
|
'hover:aria-selected:bg-primary hover:aria-selected:text-primary-foreground'
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const DATE_TIME_PICKER_LAYOUT_CLASS_NAMES = {
|
|
2
|
+
calendar: 'min-w-0 overflow-x-auto p-2',
|
|
3
|
+
container: '@container min-w-0 overflow-hidden',
|
|
4
|
+
layout: 'flex min-w-0 flex-col @[34rem]:flex-row',
|
|
5
|
+
time: 'flex min-w-0 flex-col gap-3 border-t p-3 @[34rem]:w-52 @[34rem]:shrink-0 @[34rem]:border-t-0 @[34rem]:border-l',
|
|
6
|
+
} as const;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DATE_TIME_PICKER_LAYOUT_CLASS_NAMES } from './date-time-picker-layout';
|
|
3
|
+
|
|
4
|
+
describe('DateTimePicker responsive layout', () => {
|
|
5
|
+
it('switches calendar and time controls from the picker container width', () => {
|
|
6
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.container).toContain(
|
|
7
|
+
'@container'
|
|
8
|
+
);
|
|
9
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.layout).toContain(
|
|
10
|
+
'@[34rem]:flex-row'
|
|
11
|
+
);
|
|
12
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.time).toContain(
|
|
13
|
+
'@[34rem]:border-l'
|
|
14
|
+
);
|
|
15
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.layout).not.toContain('sm:');
|
|
16
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.time).not.toContain('sm:');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('contains narrow calendars instead of letting them escape the dialog', () => {
|
|
20
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.calendar).toContain('min-w-0');
|
|
21
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.calendar).toContain(
|
|
22
|
+
'overflow-x-auto'
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
useRef,
|
|
32
32
|
useState,
|
|
33
33
|
} from 'react';
|
|
34
|
+
import { DATE_TIME_PICKER_LAYOUT_CLASS_NAMES } from './date-time-picker-layout';
|
|
34
35
|
import { Separator } from './separator';
|
|
35
36
|
|
|
36
37
|
interface DateTimePickerProps {
|
|
@@ -63,7 +64,6 @@ interface DateTimePickerProps {
|
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
// Utility to find the nearest scrollable parent
|
|
67
67
|
function getScrollableParent(node: HTMLElement | null): HTMLElement | null {
|
|
68
68
|
if (!node) return null;
|
|
69
69
|
let parent = node.parentElement;
|
|
@@ -530,7 +530,7 @@ export function DateTimePicker({
|
|
|
530
530
|
: undefined;
|
|
531
531
|
|
|
532
532
|
const timeControl = showTimeSelect ? (
|
|
533
|
-
<div className=
|
|
533
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.time}>
|
|
534
534
|
<div className="space-y-1">
|
|
535
535
|
<div className="flex items-center gap-2 font-medium text-sm">
|
|
536
536
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
@@ -628,7 +628,7 @@ export function DateTimePicker({
|
|
|
628
628
|
|
|
629
629
|
// Inline content (used both for inline mode and popover content)
|
|
630
630
|
const pickerContent = (
|
|
631
|
-
<div className=
|
|
631
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.container}>
|
|
632
632
|
{date && (
|
|
633
633
|
<div className="border-b bg-muted/30 px-3 py-2">
|
|
634
634
|
<div className="flex min-w-0 items-center gap-2 text-sm">
|
|
@@ -646,8 +646,8 @@ export function DateTimePicker({
|
|
|
646
646
|
</div>
|
|
647
647
|
)}
|
|
648
648
|
|
|
649
|
-
<div className=
|
|
650
|
-
<div className=
|
|
649
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.layout}>
|
|
650
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.calendar}>
|
|
651
651
|
<Calendar
|
|
652
652
|
mode="single"
|
|
653
653
|
selected={date}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __imageExtensionPrivate } from '../image-extension';
|
|
3
|
+
|
|
4
|
+
describe('ImageExtension clipboard files', () => {
|
|
5
|
+
it('keeps images when screenshot clipboards advertise text and HTML', () => {
|
|
6
|
+
const imageFile = new File(['image'], 'screenshot.png', {
|
|
7
|
+
type: 'image/png',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const images = __imageExtensionPrivate.getClipboardImageFiles([
|
|
11
|
+
{ type: 'text/plain', getAsFile: () => null } as DataTransferItem,
|
|
12
|
+
{ type: 'text/html', getAsFile: () => null } as DataTransferItem,
|
|
13
|
+
{
|
|
14
|
+
type: 'image/png',
|
|
15
|
+
getAsFile: () => imageFile,
|
|
16
|
+
} as DataTransferItem,
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
expect(images).toEqual([imageFile]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { __markdownPastePrivate } from '../markdown-paste-extension';
|
|
3
3
|
|
|
4
|
-
const { markdownToHtml } =
|
|
4
|
+
const { markdownToHtml, looksLikeMarkdown, normalizePastedPlainText } =
|
|
5
|
+
__markdownPastePrivate;
|
|
5
6
|
|
|
6
7
|
describe('markdownToHtml', () => {
|
|
7
8
|
it('should convert headings', () => {
|
|
@@ -47,6 +48,27 @@ describe('markdownToHtml', () => {
|
|
|
47
48
|
expect(html).toContain('<li><p>item 2</p></li>');
|
|
48
49
|
});
|
|
49
50
|
|
|
51
|
+
it('normalizes copied visual bullets into structured list markers', () => {
|
|
52
|
+
const text = normalizePastedPlainText(
|
|
53
|
+
'Next steps\r\n• Draft the interview plan\r\n◦ Share it with the team'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(text).toBe(
|
|
57
|
+
'Next steps\n- Draft the interview plan\n- Share it with the team'
|
|
58
|
+
);
|
|
59
|
+
expect(looksLikeMarkdown(text)).toBe(true);
|
|
60
|
+
expect(markdownToHtml(text)).toContain(
|
|
61
|
+
'<ul><li><p>Draft the interview plan</p></li><li><p>Share it with the team</p></li></ul>'
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('normalizes copied visual checkboxes into task list markers', () => {
|
|
66
|
+
const text = normalizePastedPlainText('☐ Plan interviews\n☑ Invite team');
|
|
67
|
+
|
|
68
|
+
expect(text).toBe('- [ ] Plan interviews\n- [x] Invite team');
|
|
69
|
+
expect(markdownToHtml(text)).toContain('data-type="taskList"');
|
|
70
|
+
});
|
|
71
|
+
|
|
50
72
|
it('should convert ordered lists', () => {
|
|
51
73
|
const md = '1. first\n2. second';
|
|
52
74
|
const html = markdownToHtml(md);
|
|
@@ -210,6 +232,12 @@ describe('markdownToHtml', () => {
|
|
|
210
232
|
expect(html).toContain('<p>Hello world</p>');
|
|
211
233
|
});
|
|
212
234
|
|
|
235
|
+
it('preserves intentional line breaks inside pasted paragraphs', () => {
|
|
236
|
+
const html = markdownToHtml('Line one\nLine two');
|
|
237
|
+
|
|
238
|
+
expect(html).toContain('<p>Line one<br>Line two</p>');
|
|
239
|
+
});
|
|
240
|
+
|
|
213
241
|
it('should escape HTML in plain text', () => {
|
|
214
242
|
const md = '<script>alert(1)</script>';
|
|
215
243
|
const html = markdownToHtml(md);
|
|
@@ -85,9 +85,18 @@ function resolveUploadHandler({
|
|
|
85
85
|
return configuredHandler;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function getClipboardImageFiles(
|
|
89
|
+
items: DataTransferItemList | DataTransferItem[]
|
|
90
|
+
) {
|
|
91
|
+
return Array.from(items)
|
|
92
|
+
.map((item) => (item.type.startsWith('image/') ? item.getAsFile() : null))
|
|
93
|
+
.filter((file): file is File => file !== null);
|
|
94
|
+
}
|
|
95
|
+
|
|
88
96
|
export const __imageExtensionPrivate = {
|
|
89
97
|
clearImageResizeUIFromNodeDom,
|
|
90
98
|
getSelectedImagePos,
|
|
99
|
+
getClipboardImageFiles,
|
|
91
100
|
resolveUploadHandler,
|
|
92
101
|
};
|
|
93
102
|
|
|
@@ -437,23 +446,10 @@ export const CustomImage = (options: ImageOptions = {}) => {
|
|
|
437
446
|
if (!items) return false;
|
|
438
447
|
|
|
439
448
|
// Filter and collect image files
|
|
440
|
-
const images =
|
|
441
|
-
.map((item) =>
|
|
442
|
-
item.type.startsWith('image/') ? item.getAsFile() : null
|
|
443
|
-
)
|
|
444
|
-
.filter((file): file is File => file !== null);
|
|
449
|
+
const images = getClipboardImageFiles(items);
|
|
445
450
|
|
|
446
451
|
if (images.length === 0) return false;
|
|
447
452
|
|
|
448
|
-
const hasTextOrHtml = Array.from(items).some(
|
|
449
|
-
(item) =>
|
|
450
|
-
item.type === 'text/plain' || item.type === 'text/html'
|
|
451
|
-
);
|
|
452
|
-
|
|
453
|
-
if (hasTextOrHtml) {
|
|
454
|
-
return false;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
453
|
const onImageUpload = getOnImageUpload();
|
|
458
454
|
if (!onImageUpload) {
|
|
459
455
|
event.preventDefault();
|
|
@@ -27,6 +27,26 @@ const BLOCK_START_PATTERNS = [
|
|
|
27
27
|
/^\s*\|/,
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
+
const VISUAL_BULLET_PATTERN = /^(\s*)[•◦▪‣●]\s+/u;
|
|
31
|
+
const VISUAL_CHECKBOX_PATTERN = /^(\s*)[☐☑☒]\s+/u;
|
|
32
|
+
|
|
33
|
+
function normalizePastedPlainText(text: string): string {
|
|
34
|
+
return text
|
|
35
|
+
.replace(/\r\n?/g, '\n')
|
|
36
|
+
.split('\n')
|
|
37
|
+
.map((line) => {
|
|
38
|
+
const checkboxMatch = line.match(VISUAL_CHECKBOX_PATTERN);
|
|
39
|
+
if (checkboxMatch) {
|
|
40
|
+
const marker = line.trimStart().charAt(0);
|
|
41
|
+
const content = line.replace(VISUAL_CHECKBOX_PATTERN, '');
|
|
42
|
+
return `${checkboxMatch[1] ?? ''}- [${marker === '☐' ? ' ' : 'x'}] ${content}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return line.replace(VISUAL_BULLET_PATTERN, '$1- ');
|
|
46
|
+
})
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
function isBlockStart(line: string): boolean {
|
|
31
51
|
return BLOCK_START_PATTERNS.some((p) => p.test(line));
|
|
32
52
|
}
|
|
@@ -496,7 +516,7 @@ function markdownToHtml(markdown: string): string {
|
|
|
496
516
|
paraLines.push(lines[i]!);
|
|
497
517
|
i++;
|
|
498
518
|
}
|
|
499
|
-
result.push(`<p>${
|
|
519
|
+
result.push(`<p>${paraLines.map(parseInline).join('<br>')}</p>`);
|
|
500
520
|
}
|
|
501
521
|
|
|
502
522
|
return result.join('\n');
|
|
@@ -539,6 +559,7 @@ function looksLikeMarkdown(text: string): boolean {
|
|
|
539
559
|
export const __markdownPastePrivate = {
|
|
540
560
|
markdownToHtml,
|
|
541
561
|
looksLikeMarkdown,
|
|
562
|
+
normalizePastedPlainText,
|
|
542
563
|
};
|
|
543
564
|
|
|
544
565
|
const markdownPastePluginKey = new PluginKey('markdownPastePlugin');
|
|
@@ -569,7 +590,9 @@ export const MarkdownPaste = Extension.create({
|
|
|
569
590
|
return false;
|
|
570
591
|
}
|
|
571
592
|
|
|
572
|
-
const text =
|
|
593
|
+
const text = normalizePastedPlainText(
|
|
594
|
+
clipboardData.getData('text/plain')
|
|
595
|
+
);
|
|
573
596
|
if (!text || !looksLikeMarkdown(text)) {
|
|
574
597
|
return false;
|
|
575
598
|
}
|