your personal website on atproto - mirror
blento.app
1import { Extension } from '@tiptap/core';
2import Suggestion from '@tiptap/suggestion';
3import type { Editor, Range } from '@tiptap/core';
4import { PluginKey } from '@tiptap/pm/state';
5import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion';
6import SuggestionSelect from './SuggestionSelect.svelte';
7import { mount, unmount } from 'svelte';
8import { computePosition, flip, shift, offset } from '@floating-ui/dom';
9import type { RichTextTypes } from '..';
10
11export default Extension.create({
12 name: 'slash',
13
14 addOptions() {
15 return {
16 suggestion: {
17 char: '/',
18
19 command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
20 props.command({ editor, range });
21 }
22 }
23 };
24 },
25
26 addProseMirrorPlugins() {
27 return [
28 Suggestion({
29 editor: this.editor,
30 ...this.options.suggestion
31 })
32 ];
33 }
34});
35
36export function suggestion({
37 char,
38 pluginKey,
39 switchTo,
40 processImageFile
41}: {
42 char: string;
43 pluginKey: string;
44 switchTo: (value: RichTextTypes) => void;
45 processImageFile: (file: File) => void;
46}) {
47 return {
48 char,
49 pluginKey: new PluginKey(pluginKey),
50
51 items: ({ query }: { query: string }) => {
52 return [
53 {
54 value: 'paragraph',
55 label: 'Paragraph',
56 command: ({ editor, range }: { editor: Editor; range: Range }) => {
57 editor.chain().focus().deleteRange(range).run();
58 switchTo('paragraph');
59 }
60 },
61 {
62 value: 'heading-1',
63 label: 'Heading 1',
64 command: ({ editor, range }: { editor: Editor; range: Range }) => {
65 editor.chain().focus().deleteRange(range).run();
66 switchTo('heading-1');
67 }
68 },
69 {
70 value: 'heading-2',
71 label: 'Heading 2',
72 command: ({ editor, range }: { editor: Editor; range: Range }) => {
73 editor.chain().focus().deleteRange(range).run();
74 switchTo('heading-2');
75 }
76 },
77 {
78 value: 'heading-3',
79 label: 'Heading 3',
80 command: ({ editor, range }: { editor: Editor; range: Range }) => {
81 editor.chain().focus().deleteRange(range).run();
82 switchTo('heading-3');
83 }
84 },
85 {
86 value: 'blockquote',
87 label: 'Blockquote',
88 command: ({ editor, range }: { editor: Editor; range: Range }) => {
89 editor.chain().focus().deleteRange(range).run();
90 switchTo('blockquote');
91 }
92 },
93 {
94 value: 'code',
95 label: 'Code Block',
96 command: ({ editor, range }: { editor: Editor; range: Range }) => {
97 editor.chain().focus().deleteRange(range).run();
98 switchTo('code');
99 }
100 },
101 {
102 value: 'bullet-list',
103 label: 'Bullet List',
104 command: ({ editor, range }: { editor: Editor; range: Range }) => {
105 editor.chain().focus().deleteRange(range).run();
106 switchTo('bullet-list');
107 }
108 },
109 {
110 value: 'ordered-list',
111 label: 'Ordered List',
112 command: ({ editor, range }: { editor: Editor; range: Range }) => {
113 editor.chain().focus().deleteRange(range).run();
114 switchTo('ordered-list');
115 }
116 },
117 {
118 value: 'image',
119 label: 'Add Image',
120 command: ({ editor, range }: { editor: Editor; range: Range }) => {
121 editor.chain().focus().deleteRange(range).run();
122
123 const fileInput = document.createElement('input');
124 fileInput.type = 'file';
125 fileInput.click();
126 fileInput.addEventListener('change', (event) => {
127 const input = event.target as HTMLInputElement;
128 if (!input.files?.length) return;
129 const file = input.files[0];
130 if (!file?.type.startsWith('image/')) return;
131 processImageFile(file);
132
133 input.remove();
134 });
135 }
136 }
137 ].filter((item) => item.label.toLowerCase().includes(query.toLowerCase()));
138 },
139
140 render: () => {
141 let component: ReturnType<typeof SuggestionSelect>;
142 let floatingEl: HTMLElement;
143
144 function updatePosition(clientRect: (() => DOMRect | null) | null | undefined) {
145 if (!clientRect || !floatingEl) return;
146 const rect = clientRect();
147 if (!rect) return;
148
149 // Create a virtual reference element for floating-ui
150 const virtualRef = {
151 getBoundingClientRect: () => rect
152 };
153
154 computePosition(virtualRef, floatingEl, {
155 placement: 'bottom-start',
156 middleware: [offset(8), flip(), shift({ padding: 8 })]
157 }).then(({ x, y }) => {
158 Object.assign(floatingEl.style, {
159 left: `${x}px`,
160 top: `${y}px`
161 });
162 });
163 }
164
165 return {
166 onStart: (props: SuggestionProps) => {
167 floatingEl = document.createElement('div');
168 floatingEl.style.position = 'absolute';
169 floatingEl.style.zIndex = '50';
170 document.body.appendChild(floatingEl);
171
172 component = mount(SuggestionSelect, {
173 target: floatingEl,
174 props
175 });
176
177 updatePosition(props.clientRect);
178 },
179 onUpdate: (props: SuggestionProps) => {
180 component.setItems(props.items);
181 component.setRange(props.range);
182 updatePosition(props.clientRect);
183 },
184 onKeyDown: (props: SuggestionKeyDownProps) => {
185 if (props.event.key === 'Escape') {
186 floatingEl.style.display = 'none';
187 return true;
188 }
189
190 return component.onKeyDown(props.event);
191 },
192 onExit: () => {
193 unmount(component);
194 floatingEl.remove();
195 }
196 };
197 }
198 };
199}