@neurocode-ai/session-ui 1.18.8

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 (114) hide show
  1. package/package.json +71 -0
  2. package/src/components/apply-patch-file.test.ts +43 -0
  3. package/src/components/apply-patch-file.ts +78 -0
  4. package/src/components/basic-tool.css +358 -0
  5. package/src/components/basic-tool.stories.tsx +133 -0
  6. package/src/components/basic-tool.tsx +343 -0
  7. package/src/components/dock-prompt.stories.tsx +62 -0
  8. package/src/components/dock-prompt.tsx +23 -0
  9. package/src/components/file-media.tsx +291 -0
  10. package/src/components/file-search.tsx +72 -0
  11. package/src/components/file-ssr.tsx +197 -0
  12. package/src/components/file.css +46 -0
  13. package/src/components/file.tsx +1202 -0
  14. package/src/components/line-comment-annotations.tsx +637 -0
  15. package/src/components/line-comment-styles.ts +292 -0
  16. package/src/components/line-comment.stories.tsx +115 -0
  17. package/src/components/line-comment.tsx +439 -0
  18. package/src/components/markdown-cache.tsx +78 -0
  19. package/src/components/markdown-code-state.test.ts +32 -0
  20. package/src/components/markdown-code-state.ts +22 -0
  21. package/src/components/markdown-inline-code-kind.test.ts +46 -0
  22. package/src/components/markdown-inline-code-kind.ts +1915 -0
  23. package/src/components/markdown-preload.test.ts +18 -0
  24. package/src/components/markdown-shiki.worker.ts +104 -0
  25. package/src/components/markdown-stream.test.ts +194 -0
  26. package/src/components/markdown-stream.ts +110 -0
  27. package/src/components/markdown-worker-protocol.test.ts +81 -0
  28. package/src/components/markdown-worker-protocol.ts +48 -0
  29. package/src/components/markdown-worker-queue.test.ts +49 -0
  30. package/src/components/markdown-worker-queue.ts +64 -0
  31. package/src/components/markdown-worker-transport.test.ts +56 -0
  32. package/src/components/markdown-worker-transport.ts +41 -0
  33. package/src/components/markdown-worker.ts +122 -0
  34. package/src/components/markdown.css +377 -0
  35. package/src/components/markdown.stories.tsx +53 -0
  36. package/src/components/markdown.tsx +678 -0
  37. package/src/components/message-file.test.ts +63 -0
  38. package/src/components/message-file.ts +34 -0
  39. package/src/components/message-nav.css +127 -0
  40. package/src/components/message-nav.stories.tsx +7 -0
  41. package/src/components/message-nav.tsx +102 -0
  42. package/src/components/message-part-text.ts +3 -0
  43. package/src/components/message-part.css +1569 -0
  44. package/src/components/message-part.stories.tsx +7 -0
  45. package/src/components/message-part.test.ts +28 -0
  46. package/src/components/message-part.tsx +2662 -0
  47. package/src/components/session-diff.test.ts +135 -0
  48. package/src/components/session-diff.ts +145 -0
  49. package/src/components/session-retry.tsx +74 -0
  50. package/src/components/session-review.css +247 -0
  51. package/src/components/session-review.stories.tsx +7 -0
  52. package/src/components/session-review.tsx +657 -0
  53. package/src/components/session-turn.css +241 -0
  54. package/src/components/session-turn.stories.tsx +7 -0
  55. package/src/components/session-turn.tsx +543 -0
  56. package/src/components/shell-submessage-motion.stories.tsx +346 -0
  57. package/src/components/shell-submessage.css +23 -0
  58. package/src/components/timeline-playground.stories.tsx +2090 -0
  59. package/src/components/tool-count-label.css +57 -0
  60. package/src/components/tool-count-label.tsx +58 -0
  61. package/src/components/tool-count-summary.css +102 -0
  62. package/src/components/tool-count-summary.stories.tsx +238 -0
  63. package/src/components/tool-count-summary.tsx +52 -0
  64. package/src/components/tool-error-card.css +91 -0
  65. package/src/components/tool-error-card.stories.tsx +92 -0
  66. package/src/components/tool-error-card.tsx +157 -0
  67. package/src/components/tool-status-title.css +89 -0
  68. package/src/components/tool-status-title.tsx +133 -0
  69. package/src/context/data.tsx +64 -0
  70. package/src/context/index.ts +1 -0
  71. package/src/pierre/comment-hover.ts +83 -0
  72. package/src/pierre/commented-lines.ts +91 -0
  73. package/src/pierre/diff-selection.ts +71 -0
  74. package/src/pierre/file-find.ts +485 -0
  75. package/src/pierre/file-runtime.ts +114 -0
  76. package/src/pierre/file-selection.ts +85 -0
  77. package/src/pierre/index.ts +186 -0
  78. package/src/pierre/media.ts +110 -0
  79. package/src/pierre/selection-bridge.ts +132 -0
  80. package/src/pierre/virtualizer.ts +100 -0
  81. package/src/pierre/worker.ts +52 -0
  82. package/src/styles/index.css +14 -0
  83. package/src/v2/components/attachment-card-v2.css +70 -0
  84. package/src/v2/components/attachment-card-v2.tsx +33 -0
  85. package/src/v2/components/basic-tool-v2.css +163 -0
  86. package/src/v2/components/basic-tool-v2.stories.tsx +137 -0
  87. package/src/v2/components/basic-tool-v2.tsx +139 -0
  88. package/src/v2/components/comment-card-v2.tsx +64 -0
  89. package/src/v2/components/line-comment-annotations-v2.tsx +220 -0
  90. package/src/v2/components/prompt-input/attachments.ts +266 -0
  91. package/src/v2/components/prompt-input/index.tsx +706 -0
  92. package/src/v2/components/prompt-input/interaction.ts +482 -0
  93. package/src/v2/components/prompt-input/machine.test.ts +164 -0
  94. package/src/v2/components/prompt-input/machine.ts +261 -0
  95. package/src/v2/components/prompt-input/prompt-input.stories.tsx +221 -0
  96. package/src/v2/components/prompt-input/store.test.ts +116 -0
  97. package/src/v2/components/prompt-input/store.ts +152 -0
  98. package/src/v2/components/prompt-input/types.ts +106 -0
  99. package/src/v2/components/session-file-panel-v2.tsx +43 -0
  100. package/src/v2/components/session-progress-indicator-v2.css +875 -0
  101. package/src/v2/components/session-progress-indicator-v2.stories.tsx +66 -0
  102. package/src/v2/components/session-progress-indicator-v2.tsx +32 -0
  103. package/src/v2/components/session-review-empty-changes-v2.tsx +17 -0
  104. package/src/v2/components/session-review-empty-no-git-v2.tsx +28 -0
  105. package/src/v2/components/session-review-file-preview-v2-virtualize.test.ts +13 -0
  106. package/src/v2/components/session-review-file-preview-v2-virtualize.ts +5 -0
  107. package/src/v2/components/session-review-file-preview-v2.tsx +290 -0
  108. package/src/v2/components/session-review-v2.css +458 -0
  109. package/src/v2/components/session-review-v2.tsx +340 -0
  110. package/src/v2/components/tool-error-card-v2.css +200 -0
  111. package/src/v2/components/tool-error-card-v2.stories.tsx +91 -0
  112. package/src/v2/components/tool-error-card-v2.tsx +166 -0
  113. package/sst-env.d.ts +10 -0
  114. package/tsconfig.json +19 -0
@@ -0,0 +1,220 @@
1
+ import { type SelectedLineRange } from "@pierre/diffs"
2
+ import { Show, type Accessor, type JSX } from "solid-js"
3
+ import {
4
+ createLineCommentAnnotations,
5
+ createLineCommentGutterRenderer,
6
+ createLineCommentState,
7
+ createManagedLineCommentAnnotationRenderer,
8
+ type LineCommentShape,
9
+ type LineCommentStateProps,
10
+ } from "../../components/line-comment-annotations"
11
+ import { useI18n } from "@neurocode-ai/ui/context/i18n"
12
+ import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge"
13
+ import { LineCommentEditorV2, LineCommentV2 } from "@neurocode-ai/ui/v2/line-comment-v2"
14
+ import type { LineCommentEditorV2Mention } from "@neurocode-ai/ui/v2/line-comment-v2"
15
+
16
+ type LineCommentControllerV2Props<T extends LineCommentShape> = {
17
+ comments: Accessor<T[]>
18
+ draftKey: Accessor<string>
19
+ label: string
20
+ state: LineCommentStateProps<string>
21
+ getSide: (range: SelectedLineRange) => "additions" | "deletions"
22
+ onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
23
+ onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
24
+ onDelete?: (comment: T) => void
25
+ renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
26
+ editSubmitLabel?: string
27
+ mention?: LineCommentEditorV2Mention
28
+ }
29
+
30
+ type CommentProps = {
31
+ id?: string
32
+ comment: JSX.Element
33
+ selection: JSX.Element
34
+ actions?: JSX.Element
35
+ editor?: DraftProps
36
+ onClick?: JSX.EventHandlerUnion<HTMLDivElement, MouseEvent>
37
+ onMouseEnter?: JSX.EventHandlerUnion<HTMLDivElement, MouseEvent>
38
+ }
39
+
40
+ type DraftProps = {
41
+ value: string
42
+ selection: JSX.Element
43
+ onInput: (value: string) => void
44
+ onCancel: VoidFunction
45
+ onSubmit: (value: string) => void
46
+ cancelLabel?: string
47
+ submitLabel?: string
48
+ mention?: LineCommentEditorV2Mention
49
+ }
50
+
51
+ function lineCommentElementV2(view: Accessor<CommentProps>) {
52
+ return (
53
+ <Show
54
+ when={view().editor}
55
+ fallback={
56
+ <div
57
+ data-prevent-autofocus=""
58
+ data-comment-id={view().id}
59
+ onMouseDown={(event) => event.stopPropagation()}
60
+ onClick={view().onClick}
61
+ onMouseEnter={view().onMouseEnter}
62
+ >
63
+ <LineCommentV2 comment={view().comment} selection={view().selection} actions={view().actions} />
64
+ </div>
65
+ }
66
+ >
67
+ <div data-prevent-autofocus="" data-comment-id={view().id} onMouseDown={(event) => event.stopPropagation()}>
68
+ <LineCommentEditorV2
69
+ value={view().editor!.value}
70
+ selection={view().editor!.selection}
71
+ onInput={view().editor!.onInput}
72
+ onCancel={view().editor!.onCancel}
73
+ onSubmit={view().editor!.onSubmit}
74
+ cancelLabel={view().editor!.cancelLabel}
75
+ submitLabel={view().editor!.submitLabel}
76
+ mention={view().editor!.mention}
77
+ />
78
+ </div>
79
+ </Show>
80
+ )
81
+ }
82
+
83
+ function lineCommentDraftElementV2(view: Accessor<DraftProps>) {
84
+ return (
85
+ <div data-prevent-autofocus="" onMouseDown={(event) => event.stopPropagation()}>
86
+ <LineCommentEditorV2
87
+ value={view().value}
88
+ selection={view().selection}
89
+ onInput={view().onInput}
90
+ onCancel={view().onCancel}
91
+ onSubmit={view().onSubmit}
92
+ cancelLabel={view().cancelLabel}
93
+ submitLabel={view().submitLabel}
94
+ mention={view().mention}
95
+ />
96
+ </div>
97
+ )
98
+ }
99
+
100
+ export function createLineCommentControllerV2<T extends LineCommentShape>(props: LineCommentControllerV2Props<T>) {
101
+ const i18n = useI18n()
102
+ const note = createLineCommentState<string>(props.state)
103
+
104
+ const annotations = createLineCommentAnnotations({
105
+ comments: props.comments,
106
+ getCommentId: (comment) => comment.id,
107
+ getCommentSelection: (comment) => comment.selection,
108
+ draftRange: note.commenting,
109
+ draftKey: props.draftKey,
110
+ getSide: props.getSide,
111
+ })
112
+
113
+ const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps>({
114
+ annotations,
115
+ commentElement: lineCommentElementV2,
116
+ draftElement: lineCommentDraftElementV2,
117
+ renderComment: (comment) => {
118
+ const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
119
+ const remove = () => {
120
+ note.reset()
121
+ props.onDelete?.(comment)
122
+ }
123
+
124
+ return {
125
+ id: comment.id,
126
+ comment: comment.comment,
127
+ selection: formatSelectedLineLabel(comment.selection, i18n.t),
128
+ get actions() {
129
+ return props.renderCommentActions?.(comment, { edit, remove })
130
+ },
131
+ get editor() {
132
+ return note.isEditing(comment.id)
133
+ ? {
134
+ get value() {
135
+ return note.draft()
136
+ },
137
+ selection: formatSelectedLineLabel(comment.selection, i18n.t),
138
+ onInput: note.setDraft,
139
+ onCancel: note.cancelDraft,
140
+ onSubmit: (value: string) => {
141
+ props.onUpdate?.({
142
+ id: comment.id,
143
+ comment: value,
144
+ selection: cloneSelectedLineRange(comment.selection),
145
+ })
146
+ note.cancelDraft()
147
+ },
148
+ cancelLabel: i18n.t("ui.lineComment.cancel"),
149
+ submitLabel: props.editSubmitLabel,
150
+ mention: props.mention,
151
+ }
152
+ : undefined
153
+ },
154
+ onMouseEnter: () => note.hoverComment(comment.selection),
155
+ onClick: () => {
156
+ if (note.isEditing(comment.id)) return
157
+ note.toggleComment(comment.id, comment.selection)
158
+ },
159
+ }
160
+ },
161
+ renderDraft: (range) => ({
162
+ get value() {
163
+ return note.draft()
164
+ },
165
+ selection: formatSelectedLineLabel(range, i18n.t),
166
+ onInput: note.setDraft,
167
+ onCancel: note.cancelDraft,
168
+ onSubmit: (comment) => {
169
+ props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
170
+ note.cancelDraft()
171
+ },
172
+ cancelLabel: i18n.t("ui.lineComment.cancel"),
173
+ submitLabel: i18n.t("ui.lineComment.submit"),
174
+ mention: props.mention,
175
+ }),
176
+ })
177
+
178
+ const renderGutterUtility = createLineCommentGutterRenderer({
179
+ label: props.label,
180
+ getSelectedRange: () => {
181
+ if (note.opened()) return null
182
+ return note.selected()
183
+ },
184
+ onOpenDraft: note.openDraft,
185
+ })
186
+
187
+ const onLineSelected = (range: SelectedLineRange | null) => {
188
+ if (!range) {
189
+ note.select(null)
190
+ note.cancelDraft()
191
+ return
192
+ }
193
+
194
+ note.select(range)
195
+ }
196
+
197
+ const onLineSelectionEnd = (range: SelectedLineRange | null) => {
198
+ if (!range) {
199
+ note.cancelDraft()
200
+ return
201
+ }
202
+
203
+ note.openDraft(range)
204
+ }
205
+
206
+ const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
207
+ if (!range) return
208
+ note.openDraft(range)
209
+ }
210
+
211
+ return {
212
+ note,
213
+ annotations,
214
+ renderAnnotation,
215
+ renderGutterUtility,
216
+ onLineSelected,
217
+ onLineSelectionEnd,
218
+ onLineNumberSelectionEnd,
219
+ }
220
+ }
@@ -0,0 +1,266 @@
1
+ import { onMount } from "solid-js"
2
+ import { makeEventListener } from "@solid-primitives/event-listener"
3
+ import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types"
4
+
5
+ const accepted = [
6
+ "image/png",
7
+ "image/jpeg",
8
+ "image/gif",
9
+ "image/webp",
10
+ "application/pdf",
11
+ "text/*",
12
+ "application/json",
13
+ "application/ld+json",
14
+ "application/toml",
15
+ "application/x-toml",
16
+ "application/x-yaml",
17
+ "application/xml",
18
+ "application/yaml",
19
+ ".c",
20
+ ".cc",
21
+ ".cjs",
22
+ ".conf",
23
+ ".cpp",
24
+ ".css",
25
+ ".csv",
26
+ ".cts",
27
+ ".env",
28
+ ".go",
29
+ ".gql",
30
+ ".graphql",
31
+ ".h",
32
+ ".hh",
33
+ ".hpp",
34
+ ".htm",
35
+ ".html",
36
+ ".ini",
37
+ ".java",
38
+ ".js",
39
+ ".json",
40
+ ".jsx",
41
+ ".log",
42
+ ".md",
43
+ ".mdx",
44
+ ".mjs",
45
+ ".mts",
46
+ ".py",
47
+ ".rb",
48
+ ".rs",
49
+ ".sass",
50
+ ".scss",
51
+ ".sh",
52
+ ".sql",
53
+ ".toml",
54
+ ".ts",
55
+ ".tsx",
56
+ ".txt",
57
+ ".xml",
58
+ ".yaml",
59
+ ".yml",
60
+ ".zsh",
61
+ ]
62
+
63
+ type PromptTarget = {
64
+ current: () => PromptInputV2Prompt
65
+ cursor: () => number | undefined
66
+ set: (prompt: PromptInputV2Prompt, cursor?: number) => void
67
+ }
68
+
69
+ export type PromptInputV2AttachmentConfig = {
70
+ picker?: (
71
+ options: { defaultPath?: string; multiple?: boolean; accept?: string[] },
72
+ onFile: (file: File) => Promise<unknown>,
73
+ ) => Promise<void>
74
+ directory: () => string
75
+ isDialogActive: () => boolean
76
+ warn: () => void
77
+ onError: (error: unknown) => void
78
+ readClipboardImage?: () => Promise<File | null>
79
+ getPathForFile?: (file: File) => string
80
+ }
81
+
82
+ export function createPromptInputV2Attachments(
83
+ input: PromptInputV2AttachmentConfig & {
84
+ capture: () => PromptTarget
85
+ editor: () => HTMLElement | undefined
86
+ focusEditor: () => void
87
+ addPart: (part: PromptInputV2Prompt[number]) => boolean
88
+ setDraggingType: (type: "image" | "@mention" | null) => void
89
+ },
90
+ ) {
91
+ const capture = () => {
92
+ const prompt = input.capture()
93
+ const editor = input.editor()
94
+ if (!editor) return
95
+ return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
96
+ }
97
+ const add = async (file: File, toast = true, target = capture()) => {
98
+ if (!target) return false
99
+ const mime = await attachmentMime(file)
100
+ if (!mime) {
101
+ if (toast) input.warn()
102
+ return false
103
+ }
104
+ const url = await dataUrl(file, mime)
105
+ if (!url) return false
106
+ const attachment: PromptInputV2Attachment = {
107
+ type: "image",
108
+ id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2),
109
+ filename: file.name,
110
+ sourcePath: input.getPathForFile?.(file) || undefined,
111
+ mime,
112
+ dataUrl: url,
113
+ }
114
+ target.prompt.set([...target.prompt.current(), attachment], target.cursor)
115
+ return true
116
+ }
117
+ const addAttachments = async (files: File[], toast = true, target = capture()) => {
118
+ const found = await files.reduce(async (result, file) => {
119
+ const previous = await result
120
+ return (await add(file, false, target)) || previous
121
+ }, Promise.resolve(false))
122
+ if (!found && files.length > 0 && toast) input.warn()
123
+ return found
124
+ }
125
+ const handlePaste = async (event: ClipboardEvent) => {
126
+ const clipboardData = event.clipboardData
127
+ if (!clipboardData) return
128
+ const target = capture()
129
+ if (!target) return
130
+ event.preventDefault()
131
+ event.stopPropagation()
132
+ const files = Array.from(clipboardData.items).flatMap((item) => {
133
+ if (item.kind !== "file") return []
134
+ const file = item.getAsFile()
135
+ return file ? [file] : []
136
+ })
137
+ if (files.length > 0) {
138
+ await addAttachments(files, true, target)
139
+ return
140
+ }
141
+ const plainText = clipboardData.getData("text/plain") ?? ""
142
+ if (input.readClipboardImage && !plainText) {
143
+ const file = await input.readClipboardImage()
144
+ if (file && (await add(file, true, target))) return
145
+ }
146
+ if (!plainText) return
147
+ const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
148
+ const put = () => {
149
+ if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
150
+ input.focusEditor()
151
+ return input.addPart({ type: "text", content: text, start: 0, end: 0 })
152
+ }
153
+ if (text.includes("\n") || largePaste(text)) {
154
+ put()
155
+ return
156
+ }
157
+ if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return
158
+ put()
159
+ }
160
+ const handleDrop = async (event: DragEvent) => {
161
+ if (input.isDialogActive()) return
162
+ event.preventDefault()
163
+ input.setDraggingType(null)
164
+ const plainText = event.dataTransfer?.getData("text/plain")
165
+ if (plainText?.startsWith("file:")) {
166
+ const path = plainText.slice("file:".length)
167
+ input.focusEditor()
168
+ input.addPart({ type: "file", path, content: `@${path}`, start: 0, end: 0 })
169
+ return
170
+ }
171
+ const files = event.dataTransfer?.files
172
+ if (files) await addAttachments(Array.from(files))
173
+ }
174
+
175
+ onMount(() => {
176
+ makeEventListener(document, "dragover", (event) => {
177
+ if (input.isDialogActive()) return
178
+ event.preventDefault()
179
+ if (event.dataTransfer?.types.includes("Files")) input.setDraggingType("image")
180
+ else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
181
+ })
182
+ makeEventListener(document, "dragleave", (event) => {
183
+ if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
184
+ })
185
+ makeEventListener(document, "drop", handleDrop)
186
+ })
187
+
188
+ return {
189
+ addAttachments,
190
+ handlePaste,
191
+ handleDrop,
192
+ pick(fallback: () => void) {
193
+ if (!input.picker) {
194
+ fallback()
195
+ return
196
+ }
197
+ void input
198
+ .picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file))
199
+ .catch(input.onError)
200
+ },
201
+ }
202
+ }
203
+
204
+ function dataUrl(file: File, mime: string) {
205
+ return new Promise<string>((resolve) => {
206
+ const reader = new FileReader()
207
+ reader.addEventListener("error", () => resolve(""))
208
+ reader.addEventListener("load", () => {
209
+ const value = typeof reader.result === "string" ? reader.result : ""
210
+ const index = value.indexOf(",")
211
+ resolve(index === -1 ? value : `data:${mime};base64,${value.slice(index + 1)}`)
212
+ })
213
+ reader.readAsDataURL(file)
214
+ })
215
+ }
216
+
217
+ const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
218
+ const imageExtensions = new Map([
219
+ ["gif", "image/gif"],
220
+ ["jpeg", "image/jpeg"],
221
+ ["jpg", "image/jpeg"],
222
+ ["png", "image/png"],
223
+ ["webp", "image/webp"],
224
+ ])
225
+ const textMimes = new Set([
226
+ "application/json",
227
+ "application/ld+json",
228
+ "application/toml",
229
+ "application/x-toml",
230
+ "application/x-yaml",
231
+ "application/xml",
232
+ "application/yaml",
233
+ ])
234
+
235
+ async function attachmentMime(file: File) {
236
+ const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
237
+ if (imageMimes.has(type) || type === "application/pdf") return type
238
+ const index = file.name.lastIndexOf(".")
239
+ const suffix = index === -1 ? "" : file.name.slice(index + 1).toLowerCase()
240
+ const fallback = imageExtensions.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
241
+ if ((!type || type === "application/octet-stream") && fallback) return fallback
242
+ if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) {
243
+ return "text/plain"
244
+ }
245
+ const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer())
246
+ if (bytes.some((byte) => byte === 0)) return
247
+ const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
248
+ if (bytes.length > 0 && control / bytes.length > 0.3) return
249
+ return "text/plain"
250
+ }
251
+
252
+ function cursorPosition(editor: HTMLElement) {
253
+ const selection = window.getSelection()
254
+ if (!selection || selection.rangeCount === 0) return 0
255
+ const range = selection.getRangeAt(0)
256
+ if (!editor.contains(range.startContainer)) return 0
257
+ const before = range.cloneRange()
258
+ before.selectNodeContents(editor)
259
+ before.setEnd(range.startContainer, range.startOffset)
260
+ return before.toString().replace(/\u200B/g, "").length
261
+ }
262
+
263
+ function largePaste(text: string) {
264
+ if (text.length >= 8000) return true
265
+ return text.split("\n").length - 1 >= 120
266
+ }