@foxui/text 0.4.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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/dist/components/advanced-text-area/AdvancedTextArea.svelte +58 -0
  4. package/dist/components/advanced-text-area/AdvancedTextArea.svelte.d.ts +12 -0
  5. package/dist/components/advanced-text-area/index.d.ts +1 -0
  6. package/dist/components/advanced-text-area/index.js +1 -0
  7. package/dist/components/index.d.ts +3 -0
  8. package/dist/components/index.js +3 -0
  9. package/dist/components/plain-text-editor/PlainTextEditor.svelte +133 -0
  10. package/dist/components/plain-text-editor/PlainTextEditor.svelte.d.ts +14 -0
  11. package/dist/components/plain-text-editor/index.d.ts +1 -0
  12. package/dist/components/plain-text-editor/index.js +1 -0
  13. package/dist/components/rich-text-editor/Icon.svelte +120 -0
  14. package/dist/components/rich-text-editor/Icon.svelte.d.ts +7 -0
  15. package/dist/components/rich-text-editor/RichTextEditor.svelte +427 -0
  16. package/dist/components/rich-text-editor/RichTextEditor.svelte.d.ts +18 -0
  17. package/dist/components/rich-text-editor/RichTextEditorLinkMenu.svelte +95 -0
  18. package/dist/components/rich-text-editor/RichTextEditorLinkMenu.svelte.d.ts +10 -0
  19. package/dist/components/rich-text-editor/RichTextEditorMenu.svelte +184 -0
  20. package/dist/components/rich-text-editor/RichTextEditorMenu.svelte.d.ts +19 -0
  21. package/dist/components/rich-text-editor/RichTextLink.d.ts +13 -0
  22. package/dist/components/rich-text-editor/RichTextLink.js +105 -0
  23. package/dist/components/rich-text-editor/Select.svelte +72 -0
  24. package/dist/components/rich-text-editor/Select.svelte.d.ts +13 -0
  25. package/dist/components/rich-text-editor/code.css +142 -0
  26. package/dist/components/rich-text-editor/image-upload/ImageUploadComponent.svelte +47 -0
  27. package/dist/components/rich-text-editor/image-upload/ImageUploadComponent.svelte.d.ts +4 -0
  28. package/dist/components/rich-text-editor/image-upload/ImageUploadNode.d.ts +45 -0
  29. package/dist/components/rich-text-editor/image-upload/ImageUploadNode.js +56 -0
  30. package/dist/components/rich-text-editor/index.d.ts +2 -0
  31. package/dist/components/rich-text-editor/index.js +1 -0
  32. package/dist/components/rich-text-editor/slash-menu/SuggestionSelect.svelte +88 -0
  33. package/dist/components/rich-text-editor/slash-menu/SuggestionSelect.svelte.d.ts +22 -0
  34. package/dist/components/rich-text-editor/slash-menu/index.d.ts +32 -0
  35. package/dist/components/rich-text-editor/slash-menu/index.js +168 -0
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +1 -0
  38. package/dist/types.d.ts +1 -0
  39. package/package.json +93 -0
@@ -0,0 +1,427 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { Editor, mergeAttributes, type Content } from '@tiptap/core';
4
+ import StarterKit from '@tiptap/starter-kit';
5
+ import Placeholder from '@tiptap/extension-placeholder';
6
+ import Image from '@tiptap/extension-image';
7
+ import { all, createLowlight } from 'lowlight';
8
+ import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
9
+ import BubbleMenu from '@tiptap/extension-bubble-menu';
10
+ import Underline from '@tiptap/extension-underline';
11
+ import RichTextEditorMenu from './RichTextEditorMenu.svelte';
12
+ import type { RichTextTypes } from '.';
13
+ import RichTextEditorLinkMenu from './RichTextEditorLinkMenu.svelte';
14
+ import Slash, { suggestion } from './slash-menu';
15
+ import Typography from '@tiptap/extension-typography';
16
+ import { RichTextLink } from './RichTextLink';
17
+
18
+ import './code.css';
19
+ import { cn } from '@foxui/core';
20
+ import { ImageUploadNode } from './image-upload/ImageUploadNode';
21
+ import { Transaction } from '@tiptap/pm/state';
22
+
23
+ let {
24
+ content = $bindable({}),
25
+ placeholder = 'Write or press / for commands',
26
+ editor = $bindable(null),
27
+ ref = $bindable(null),
28
+ class: className,
29
+ onupdate,
30
+ ontransaction
31
+ }: {
32
+ content?: Content;
33
+ placeholder?: string;
34
+ editor?: Editor | null;
35
+ ref?: HTMLDivElement | null;
36
+ class?: string;
37
+ onupdate?: (content: Content, context: { editor: Editor; transaction: Transaction }) => void;
38
+ ontransaction?: () => void;
39
+ } = $props();
40
+
41
+ const lowlight = createLowlight(all);
42
+
43
+ let hasFocus = true;
44
+
45
+ let menu: HTMLElement | null = $state(null);
46
+ let menuLink: HTMLElement | null = $state(null);
47
+
48
+ let selectedType: RichTextTypes = $state('paragraph');
49
+
50
+ let isBold = $state(false);
51
+ let isItalic = $state(false);
52
+ let isUnderline = $state(false);
53
+ let isStrikethrough = $state(false);
54
+ let isLink = $state(false);
55
+ let isImage = $state(false);
56
+
57
+ const CustomImage = Image.extend({
58
+ // addAttributes(this) {
59
+ // return {
60
+ // inline: true,
61
+ // allowBase64: true,
62
+ // HTMLAttributes: {},
63
+ // uploadImageHandler: { default: undefined }
64
+ // };
65
+ // },
66
+ renderHTML({ HTMLAttributes }) {
67
+ const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
68
+ uploadImageHandler: undefined
69
+ });
70
+ const isLocal = attrs['data-local'] === 'true';
71
+
72
+ // if (isLocal) {
73
+ // // For local images, wrap in a container with a label
74
+ // return [
75
+ // 'div',
76
+ // { class: 'image-container' },
77
+ // ['img', { ...attrs, class: `${attrs.class || ''} local-image` }],
78
+ // ['span', { class: 'local-image-label' }, 'Local preview']
79
+ // ];
80
+ // }
81
+
82
+ console.log('attrs', attrs);
83
+
84
+ // For regular images, just return the img tag
85
+ return ['img', attrs];
86
+ }
87
+ });
88
+
89
+ onMount(() => {
90
+ if (!ref) return;
91
+
92
+ let extensions = [
93
+ StarterKit.configure({
94
+ dropcursor: {
95
+ class: 'text-accent-500/30 rounded-2xl',
96
+ width: 2
97
+ },
98
+ codeBlock: false,
99
+ heading: {
100
+ levels: [1, 2, 3]
101
+ }
102
+ }),
103
+ Placeholder.configure({
104
+ placeholder: ({ node }) => {
105
+ // only show in paragraphs
106
+ if (node.type.name === 'paragraph' || node.type.name === 'heading') {
107
+ return placeholder;
108
+ }
109
+ return '';
110
+ }
111
+ }),
112
+ CustomImage.configure({
113
+ HTMLAttributes: {
114
+ class: 'max-w-full object-contain relative rounded-2xl'
115
+ },
116
+ allowBase64: true
117
+ }),
118
+ CodeBlockLowlight.configure({
119
+ lowlight,
120
+ defaultLanguage: 'js'
121
+ }),
122
+ BubbleMenu.configure({
123
+ element: menu,
124
+ shouldShow: ({ editor }) => {
125
+ // dont show if image selected or no selection or is code block
126
+ return (
127
+ !editor.isActive('image') &&
128
+ !editor.view.state.selection.empty &&
129
+ !editor.isActive('codeBlock') &&
130
+ !editor.isActive('link') &&
131
+ !editor.isActive('imageUpload')
132
+ );
133
+ },
134
+ pluginKey: 'bubble-menu-marks'
135
+ }),
136
+ BubbleMenu.configure({
137
+ element: menuLink,
138
+ shouldShow: ({ editor }) => {
139
+ // only show if link is selected
140
+ return editor.isActive('link') && !editor.view.state.selection.empty;
141
+ },
142
+ pluginKey: 'bubble-menu-links'
143
+ }),
144
+ Underline.configure({}),
145
+ RichTextLink.configure({
146
+ openOnClick: false,
147
+ autolink: true,
148
+ defaultProtocol: 'https'
149
+ }),
150
+ Slash.configure({
151
+ suggestion: suggestion({
152
+ char: '/',
153
+ pluginKey: 'slash',
154
+ switchTo,
155
+ processImageFile
156
+ })
157
+ }),
158
+ Typography.configure(),
159
+ ImageUploadNode.configure({
160
+ upload: async (file, onProgress, abortSignal) => {
161
+ console.log('uploading image', file);
162
+ // wait 2 seconds
163
+ for (let i = 0; i < 10; i++) {
164
+ await new Promise((resolve) => setTimeout(resolve, 200));
165
+ onProgress?.({ progress: i / 10 });
166
+ }
167
+
168
+ return 'https://picsum.photos/200/300';
169
+ }
170
+ })
171
+ ];
172
+
173
+ editor = new Editor({
174
+ element: ref,
175
+ extensions,
176
+ editorProps: {
177
+ attributes: {
178
+ class: 'outline-none'
179
+ }
180
+ },
181
+ onUpdate: (ctx) => {
182
+ content = ctx.editor.getJSON();
183
+ onupdate?.(content, ctx);
184
+ },
185
+ onFocus: () => {
186
+ hasFocus = true;
187
+ },
188
+ onBlur: () => {
189
+ hasFocus = false;
190
+ },
191
+ onTransaction: (ctx) => {
192
+ isBold = ctx.editor.isActive('bold');
193
+ isItalic = ctx.editor.isActive('italic');
194
+ isUnderline = ctx.editor.isActive('underline');
195
+ isStrikethrough = ctx.editor.isActive('strike');
196
+ isLink = ctx.editor.isActive('link');
197
+ isImage = ctx.editor.isActive('image');
198
+
199
+ if (ctx.editor.isActive('heading', { level: 1 })) {
200
+ selectedType = 'heading-1';
201
+ } else if (ctx.editor.isActive('heading', { level: 2 })) {
202
+ selectedType = 'heading-2';
203
+ } else if (ctx.editor.isActive('heading', { level: 3 })) {
204
+ selectedType = 'heading-3';
205
+ } else if (ctx.editor.isActive('blockquote')) {
206
+ selectedType = 'blockquote';
207
+ } else if (ctx.editor.isActive('code')) {
208
+ selectedType = 'code';
209
+ } else if (ctx.editor.isActive('bulletList')) {
210
+ selectedType = 'bullet-list';
211
+ } else if (ctx.editor.isActive('orderedList')) {
212
+ selectedType = 'ordered-list';
213
+ } else {
214
+ selectedType = 'paragraph';
215
+ }
216
+ ontransaction?.();
217
+ },
218
+ content
219
+ });
220
+
221
+ menu?.classList.remove('hidden');
222
+ menuLink?.classList.remove('hidden');
223
+ });
224
+
225
+ // Flag to track whether a file is being dragged over the drop area
226
+ let isDragOver = $state(false);
227
+
228
+ // Store local image files for later upload
229
+ let localImages: Map<string, File> = $state(new Map());
230
+
231
+ // Track which image URLs in the editor are local previews
232
+ let localImageUrls: Set<string> = $state(new Set());
233
+
234
+ // Process image file to create a local preview
235
+ async function processImageFile(file: File) {
236
+ if (!editor) {
237
+ console.warn('Tiptap editor not initialized');
238
+ return;
239
+ }
240
+
241
+ try {
242
+ const localUrl = URL.createObjectURL(file);
243
+
244
+ localImages.set(localUrl, file);
245
+ localImageUrls.add(localUrl);
246
+
247
+ //editor.commands.setImageUploadNode();
248
+ editor
249
+ .chain()
250
+ .focus()
251
+ .setImageUploadNode({
252
+ preview: localUrl
253
+ })
254
+ .run();
255
+
256
+ // wait 2 seconds
257
+ // await new Promise((resolve) => setTimeout(resolve, 500));
258
+
259
+ // content = editor.getJSON();
260
+
261
+ // console.log('replacing image url in content');
262
+ // replaceImageUrlInContent(content, localUrl, 'https://picsum.photos/200/300');
263
+ // editor.commands.setContent(content);
264
+ } catch (error) {
265
+ console.error('Error creating image preview:', error);
266
+ }
267
+ }
268
+
269
+ const handlePaste = (event: ClipboardEvent) => {
270
+ const items = event.clipboardData?.items;
271
+ if (!items) return;
272
+ // Check for image data in clipboard
273
+ for (const item of Array.from(items)) {
274
+ if (!item.type.startsWith('image/')) continue;
275
+ const file = item.getAsFile();
276
+ if (!file) continue;
277
+ event.preventDefault();
278
+ processImageFile(file);
279
+ return;
280
+ }
281
+ };
282
+
283
+ function handleDragOver(event: DragEvent) {
284
+ event.preventDefault();
285
+ event.stopPropagation();
286
+ isDragOver = true;
287
+ }
288
+ function handleDragLeave(event: DragEvent) {
289
+ event.preventDefault();
290
+ event.stopPropagation();
291
+ isDragOver = false;
292
+ }
293
+ function handleDrop(event: DragEvent) {
294
+ event.preventDefault();
295
+ event.stopPropagation();
296
+ isDragOver = false;
297
+ if (!event.dataTransfer?.files?.length) return;
298
+ const file = event.dataTransfer.files[0];
299
+ if (file?.type.startsWith('image/')) {
300
+ processImageFile(file);
301
+ }
302
+ }
303
+
304
+ onDestroy(() => {
305
+ for (const localUrl of localImageUrls) {
306
+ URL.revokeObjectURL(localUrl);
307
+ }
308
+
309
+ editor?.destroy();
310
+ });
311
+
312
+ let link = $state('');
313
+
314
+ let linkInput: HTMLInputElement | null = $state(null);
315
+
316
+ function clickedLink() {
317
+ if (isLink) {
318
+ //tiptap?.chain().focus().unsetLink().run();
319
+ // get current link
320
+ link = editor?.getAttributes('link').href;
321
+
322
+ setTimeout(() => {
323
+ linkInput?.focus();
324
+ }, 100);
325
+ } else {
326
+ link = '';
327
+ // set link
328
+ editor?.chain().focus().setLink({ href: link }).run();
329
+
330
+ setTimeout(() => {
331
+ linkInput?.focus();
332
+ }, 100);
333
+ }
334
+ }
335
+
336
+ function switchTo(value: RichTextTypes) {
337
+ editor?.chain().focus().setParagraph().run();
338
+
339
+ if (value === 'heading-1') {
340
+ editor?.chain().focus().setNode('heading', { level: 1 }).run();
341
+ } else if (value === 'heading-2') {
342
+ editor?.chain().focus().setNode('heading', { level: 2 }).run();
343
+ } else if (value === 'heading-3') {
344
+ editor?.chain().focus().setNode('heading', { level: 3 }).run();
345
+ } else if (value === 'blockquote') {
346
+ editor?.chain().focus().setBlockquote().run();
347
+ } else if (value === 'code') {
348
+ editor?.chain().focus().setCodeBlock().run();
349
+ } else if (value === 'bullet-list') {
350
+ editor?.chain().focus().toggleBulletList().run();
351
+ } else if (value === 'ordered-list') {
352
+ editor?.chain().focus().toggleOrderedList().run();
353
+ }
354
+ }
355
+ </script>
356
+
357
+ <div
358
+ bind:this={ref}
359
+ class={cn('relative flex-1', className)}
360
+ onpaste={handlePaste}
361
+ ondragover={handleDragOver}
362
+ ondragleave={handleDragLeave}
363
+ ondrop={handleDrop}
364
+ role="region"
365
+ ></div>
366
+
367
+ <RichTextEditorMenu
368
+ bind:ref={menu}
369
+ {editor}
370
+ {isBold}
371
+ {isItalic}
372
+ {isUnderline}
373
+ {isStrikethrough}
374
+ {isLink}
375
+ {isImage}
376
+ {clickedLink}
377
+ {processImageFile}
378
+ {switchTo}
379
+ bind:selectedType
380
+ />
381
+
382
+ <RichTextEditorLinkMenu bind:ref={menuLink} {editor} bind:link bind:linkInput />
383
+
384
+ <style>
385
+ :global(.tiptap) {
386
+ :first-child {
387
+ margin-top: 0;
388
+ }
389
+
390
+ :global(img) {
391
+ display: block;
392
+ height: auto;
393
+ margin: 1.5rem 0;
394
+ max-width: 100%;
395
+
396
+ &.ProseMirror-selectednode {
397
+ outline: 3px solid var(--color-accent-500);
398
+ }
399
+ }
400
+
401
+ :global(div[data-type='image-upload']) {
402
+ &.ProseMirror-selectednode {
403
+ outline: 3px solid var(--color-accent-500);
404
+ }
405
+ }
406
+
407
+ :global(blockquote p:first-of-type::before) {
408
+ content: none;
409
+ }
410
+
411
+ :global(blockquote p:last-of-type::after) {
412
+ content: none;
413
+ }
414
+
415
+ :global(blockquote p) {
416
+ font-style: normal;
417
+ }
418
+ }
419
+
420
+ :global(.tiptap .is-empty::before) {
421
+ color: var(--color-base-500);
422
+ content: attr(data-placeholder);
423
+ float: left;
424
+ height: 0;
425
+ pointer-events: none;
426
+ }
427
+ </style>
@@ -0,0 +1,18 @@
1
+ import { Editor, type Content } from '@tiptap/core';
2
+ import './code.css';
3
+ import { Transaction } from '@tiptap/pm/state';
4
+ type $$ComponentProps = {
5
+ content?: Content;
6
+ placeholder?: string;
7
+ editor?: Editor | null;
8
+ ref?: HTMLDivElement | null;
9
+ class?: string;
10
+ onupdate?: (content: Content, context: {
11
+ editor: Editor;
12
+ transaction: Transaction;
13
+ }) => void;
14
+ ontransaction?: () => void;
15
+ };
16
+ declare const RichTextEditor: import("svelte").Component<$$ComponentProps, {}, "ref" | "editor" | "content">;
17
+ type RichTextEditor = ReturnType<typeof RichTextEditor>;
18
+ export default RichTextEditor;
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import { Button, Input } from "@foxui/core";
3
+ import type { Editor } from "@tiptap/core";
4
+
5
+ let {
6
+ editor,
7
+ link = $bindable(''),
8
+ ref = $bindable(null),
9
+ linkInput = $bindable(null)
10
+ }: {
11
+ editor: Editor | null;
12
+ link: string;
13
+ ref: HTMLElement | null;
14
+ linkInput: HTMLInputElement | null;
15
+ } = $props();
16
+
17
+ function processLink(link: string) {
18
+ return link.includes(':') ? link : `http://${link}`;
19
+ }
20
+ </script>
21
+
22
+ <div
23
+ bind:this={ref}
24
+ class="menu bg-base-50 dark:bg-base-900 relative hidden w-fit rounded-2xl px-1 py-1 shadow-lg backdrop-blur-sm"
25
+ >
26
+ <div class="flex items-center gap-1">
27
+ <Input
28
+ bind:ref={linkInput}
29
+ sizeVariant="sm"
30
+ bind:value={link}
31
+ placeholder="Enter link"
32
+ onblur={() => {
33
+ if (link === '') {
34
+ editor?.chain().focus().extendMarkRange('link').unsetLink().run();
35
+ } else {
36
+ editor?.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run();
37
+ }
38
+ }}
39
+ onkeydown={(e: KeyboardEvent) => {
40
+ if (e.key === 'Enter') {
41
+ if (link === '') {
42
+ editor?.chain().focus().extendMarkRange('link').unsetLink().run();
43
+ } else {
44
+ editor?.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run();
45
+ }
46
+ }
47
+ }}
48
+ />
49
+ <Button
50
+ size="iconSm"
51
+ onclick={() => {
52
+ if (link === '') {
53
+ editor?.chain().focus().extendMarkRange('link').unsetLink().run();
54
+ } else {
55
+ editor?.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run();
56
+ }
57
+ }}
58
+ >
59
+ <svg
60
+ xmlns="http://www.w3.org/2000/svg"
61
+ fill="none"
62
+ viewBox="0 0 24 24"
63
+ stroke-width="1.5"
64
+ stroke="currentColor"
65
+ class="size-6"
66
+ >
67
+ <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
68
+ </svg>
69
+
70
+ <span class="sr-only">save link</span>
71
+ </Button>
72
+ <Button
73
+ size="iconSm"
74
+ onclick={() => {
75
+ editor?.chain().focus().extendMarkRange('link').unsetLink().run();
76
+ }}
77
+ variant="ghost"
78
+ >
79
+ <svg
80
+ xmlns="http://www.w3.org/2000/svg"
81
+ fill="none"
82
+ viewBox="0 0 24 24"
83
+ stroke-width="1.5"
84
+ stroke="currentColor"
85
+ >
86
+ <path
87
+ stroke-linecap="round"
88
+ stroke-linejoin="round"
89
+ d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
90
+ />
91
+ </svg>
92
+ <span class="sr-only">remove link</span>
93
+ </Button>
94
+ </div>
95
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ type $$ComponentProps = {
3
+ editor: Editor | null;
4
+ link: string;
5
+ ref: HTMLElement | null;
6
+ linkInput: HTMLInputElement | null;
7
+ };
8
+ declare const RichTextEditorLinkMenu: import("svelte").Component<$$ComponentProps, {}, "ref" | "link" | "linkInput">;
9
+ type RichTextEditorLinkMenu = ReturnType<typeof RichTextEditorLinkMenu>;
10
+ export default RichTextEditorLinkMenu;