@homecode/ui 5.20.19 → 5.20.20

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.
@@ -80,6 +80,7 @@ import '../Progress/Progress.styl.js';
80
80
  import '@tiptap/extensions';
81
81
  import '@tiptap/starter-kit';
82
82
  import '../PromptComposer/PromptComposer.styl.js';
83
+ import '../../tiptap/prompt-blocks/createPromptBlocksExtension.js';
83
84
  import '@tiptap/pm/view';
84
85
  import 'linkifyjs';
85
86
  import '../../tiptap/link-highlight/nonInclusiveLink.js';
@@ -76,6 +76,7 @@ import '@tiptap/extensions';
76
76
  import '@tiptap/react';
77
77
  import '@tiptap/starter-kit';
78
78
  import '../PromptComposer/PromptComposer.styl.js';
79
+ import '../../tiptap/prompt-blocks/createPromptBlocksExtension.js';
79
80
  import '@tiptap/core';
80
81
  import '@tiptap/pm/state';
81
82
  import '@tiptap/pm/view';
@@ -1,5 +1,5 @@
1
- var css_248z = ".PromptComposer_root__gfdXN{width:100%}.PromptComposer_scroller__Sueif{flex:1;max-height:200px;min-height:40px;width:100%}.PromptComposer_editorMount__B9G-o{background:transparent;border:none;border-radius:0!important;box-shadow:none!important;display:flex;flex:1;flex-direction:column;min-height:40px;min-width:0;padding:0!important}.PromptComposer_editorMount__B9G-o:focus-within{box-shadow:none!important}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEditor__yQIpq{border:none!important;box-shadow:none!important;flex:1;margin:0;min-height:40px!important;outline:none!important;overflow:hidden!important;padding:var(--p-2) 0 0!important;resize:none!important;transition:opacity .1s ease-out;white-space:pre-wrap;word-break:break-word}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEditor__yQIpq .PromptComposer_promptComposerLink__rKhhp,.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEditor__yQIpq a{color:var(--link-color)}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEditor__yQIpq a:hover{cursor:pointer;opacity:.8}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEmptyNode__62Ylm:before{color:var(--muted-foreground);content:attr(data-placeholder);float:left;height:0;opacity:.3;pointer-events:none}";
2
- var S = {"root":"PromptComposer_root__gfdXN","scroller":"PromptComposer_scroller__Sueif","editorMount":"PromptComposer_editorMount__B9G-o","promptComposerEditor":"PromptComposer_promptComposerEditor__yQIpq","promptComposerLink":"PromptComposer_promptComposerLink__rKhhp","promptComposerEmptyNode":"PromptComposer_promptComposerEmptyNode__62Ylm"};
1
+ var css_248z = ".PromptComposer_root__gfdXN{width:100%}.PromptComposer_scroller__Sueif{flex:1;max-height:200px;min-height:40px;width:100%}.PromptComposer_editorMount__B9G-o{background:transparent;border:none;border-radius:0!important;box-shadow:none!important;display:flex;flex:1;flex-direction:column;min-height:40px;min-width:0;padding:0!important}.PromptComposer_editorMount__B9G-o:focus-within{box-shadow:none!important}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEditor__yQIpq{border:none!important;box-shadow:none!important;flex:1;margin:0;min-height:40px!important;outline:none!important;overflow:hidden!important;padding:var(--p-2) 0 0!important;resize:none!important;transition:opacity .1s ease-out;white-space:pre-wrap;word-break:break-word}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEditor__yQIpq .PromptComposer_promptComposerLink__rKhhp,.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEditor__yQIpq a{color:var(--link-color)}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEditor__yQIpq a:hover{cursor:pointer;opacity:.8}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerEmptyNode__62Ylm:before{color:var(--muted-foreground);content:attr(data-placeholder);float:left;height:0;opacity:.3;pointer-events:none}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerList__PqkM-{list-style:disc;margin:0;padding-left:1.4em}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerList__PqkM- li,.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerList__PqkM- p{margin:0}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerQuote__AGoIb{color:var(--accent-color-alpha-600);font-size:85%;margin:0 0 var(--p-1);padding:2px 0 2px calc(4px + var(--p-2));position:relative;white-space:pre-wrap}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerQuote__AGoIb:before{background:var(--accent-color);border-radius:2px;bottom:2px;content:\"\";left:2px;opacity:.3;pointer-events:none;position:absolute;top:2px;width:4px}.PromptComposer_editorMount__B9G-o .PromptComposer_promptComposerQuote__AGoIb p{margin:0}.PromptComposer_editorMount__B9G-o div.PromptComposer_promptComposerQuote__AGoIb{cursor:default;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:pre-wrap}.PromptComposer_editorMount__B9G-o div.PromptComposer_promptComposerQuote__AGoIb.ProseMirror-selectednode{background:var(--accent-color-alpha-100);border-radius:4px}.PromptComposer_editorMount__B9G-o div.PromptComposer_promptComposerQuote__AGoIb.ProseMirror-selectednode:before{opacity:.7}";
2
+ var S = {"root":"PromptComposer_root__gfdXN","scroller":"PromptComposer_scroller__Sueif","editorMount":"PromptComposer_editorMount__B9G-o","promptComposerEditor":"PromptComposer_promptComposerEditor__yQIpq","promptComposerLink":"PromptComposer_promptComposerLink__rKhhp","promptComposerEmptyNode":"PromptComposer_promptComposerEmptyNode__62Ylm","promptComposerList":"PromptComposer_promptComposerList__PqkM-","promptComposerQuote":"PromptComposer_promptComposerQuote__AGoIb"};
3
3
  if (typeof document !== 'undefined') {
4
4
  var __uilibHead = document.head || document.getElementsByTagName('head')[0];
5
5
  if (__uilibHead) {
@@ -1,17 +1,6 @@
1
1
  const PROMPT_COMPOSER_EMPTY_DOC = {
2
2
  type: 'doc',
3
3
  content: [{ type: 'paragraph' }],
4
- };
5
- function promptComposerParagraphDoc(text) {
6
- return {
7
- type: 'doc',
8
- content: [
9
- {
10
- type: 'paragraph',
11
- content: text ? [{ type: 'text', text }] : [],
12
- },
13
- ],
14
- };
15
- }
4
+ };
16
5
 
17
- export { PROMPT_COMPOSER_EMPTY_DOC, promptComposerParagraphDoc };
6
+ export { PROMPT_COMPOSER_EMPTY_DOC };
@@ -1,4 +1,5 @@
1
- import { promptComposerParagraphDoc } from './promptComposerDoc.js';
1
+ import '../../tiptap/prompt-blocks/createPromptBlocksExtension.js';
2
+ import { promptBlocksDoc } from '../../tiptap/prompt-blocks/promptBlocksText.js';
2
3
 
3
4
  const PROMPT_COMPOSER_COMMAND_CHIP_CLASS = 'prompt-composer-command-chip';
4
5
  function getPromptComposerTokenRangeBeforePos(editor, pos) {
@@ -70,7 +71,7 @@ function insertPromptComposerContentAtCaret(editor, content, options) {
70
71
  function setPromptComposerEditorText(editor, text, options) {
71
72
  if (editor.isDestroyed)
72
73
  return;
73
- editor.commands.setContent(promptComposerParagraphDoc(text));
74
+ editor.commands.setContent(promptBlocksDoc(text));
74
75
  if (options?.focus) {
75
76
  editor.commands.focus(options.atEnd === false ? 'start' : 'end');
76
77
  }
@@ -3,12 +3,14 @@ import { Placeholder } from '@tiptap/extensions';
3
3
  import { useEditor } from '@tiptap/react';
4
4
  import StarterKit from '@tiptap/starter-kit';
5
5
  import S from './PromptComposer.styl.js';
6
+ import { createPromptBlocksExtension, isInPromptBlock } from '../../tiptap/prompt-blocks/createPromptBlocksExtension.js';
7
+ import { promptBlocksDoc } from '../../tiptap/prompt-blocks/promptBlocksText.js';
6
8
  import { createLinkHighlightExtension } from '../../tiptap/link-highlight/createLinkHighlightExtension.js';
7
9
  import { NonInclusiveLink } from '../../tiptap/link-highlight/nonInclusiveLink.js';
8
10
  import { DEFAULT_CHAT_SLASH_ITEMS } from '../../tiptap/slash-mention/defaultChatSlashItems.js';
9
11
  import { createSlashMentionExtension } from '../../tiptap/slash-mention/createSlashMentionExtension.js';
10
12
  import { safePromptComposerEditorText, promptComposerSafeEditorDom, syncPromptComposerHeight } from './PromptComposer.helpers.js';
11
- import { PROMPT_COMPOSER_EMPTY_DOC, promptComposerParagraphDoc } from './promptComposerDoc.js';
13
+ import { PROMPT_COMPOSER_EMPTY_DOC } from './promptComposerDoc.js';
12
14
 
13
15
  function buildMentionConfigs({ slashCommandItems, onSlashItemCommand, mentionConfigs, atMentionItems, atMentionGetItems, onAtItemCommand, }) {
14
16
  const configs = [];
@@ -101,7 +103,7 @@ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, sla
101
103
  heading: false,
102
104
  bulletList: false,
103
105
  orderedList: false,
104
- blockquote: false,
106
+ blockquote: { HTMLAttributes: { class: S.promptComposerQuote } },
105
107
  codeBlock: false,
106
108
  horizontalRule: false,
107
109
  link: false,
@@ -113,6 +115,10 @@ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, sla
113
115
  emptyNodeClass: S.promptComposerEmptyNode,
114
116
  }),
115
117
  createLinkHighlightExtension(S.promptComposerLink),
118
+ ...createPromptBlocksExtension({
119
+ quoteClassName: S.promptComposerQuote,
120
+ listClassName: S.promptComposerList,
121
+ }),
116
122
  ];
117
123
  for (const cfg of resolvedMentionConfigs) {
118
124
  const slashChar = cfg.slashChar;
@@ -180,7 +186,8 @@ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, sla
180
186
  if (!activeEditor)
181
187
  return false;
182
188
  const currentText = activeEditor.getText();
183
- const hasNewlines = currentText.includes('\n');
189
+ // In a list or a quote being typed, Enter adds the next line.
190
+ const hasNewlines = currentText.includes('\n') || isInPromptBlock(activeEditor.state);
184
191
  const isModEnter = event.metaKey || event.ctrlKey;
185
192
  // Single line: Enter submits. Multiline: Enter inserts a line; Cmd/Ctrl+Enter submits.
186
193
  if (hasNewlines ? !isModEnter : isModEnter)
@@ -249,13 +256,13 @@ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, sla
249
256
  const next = prefillMessage;
250
257
  if (current === next)
251
258
  return;
259
+ // The app trims the prompt on blur, and a list or quote at the end always
260
+ // has an empty line after it. Resetting for that would refocus the editor
261
+ // and turn an empty last item into text.
262
+ if (next !== '' && next === current.trim())
263
+ return;
252
264
  try {
253
- if (next === '') {
254
- editor.commands.setContent(PROMPT_COMPOSER_EMPTY_DOC);
255
- }
256
- else {
257
- editor.commands.setContent(promptComposerParagraphDoc(next));
258
- }
265
+ editor.commands.setContent(promptBlocksDoc(next));
259
266
  }
260
267
  catch {
261
268
  return;
@@ -0,0 +1,260 @@
1
+ import { wrappingInputRule, Extension, Node, getTextBetween, getTextSerializersFromSchema } from '@tiptap/core';
2
+ import { BulletList } from '@tiptap/extension-list';
3
+ import { PluginKey, Plugin } from '@tiptap/pm/state';
4
+ import { promptQuoteMarkdown, PROMPT_QUOTE_NODE } from './promptBlocksText.js';
5
+
6
+ const serializersBySchema = new WeakMap();
7
+ function textSerializers(schema) {
8
+ let serializers = serializersBySchema.get(schema);
9
+ if (!serializers) {
10
+ serializers = getTextSerializersFromSchema(schema);
11
+ serializersBySchema.set(schema, serializers);
12
+ }
13
+ return serializers;
14
+ }
15
+ /** Text of a textblock, with mention chips and hard breaks as text. */
16
+ function inlineText(node, schema) {
17
+ return getTextBetween(node, { from: 0, to: node.content.size }, { blockSeparator: '\n', textSerializers: textSerializers(schema) });
18
+ }
19
+ /**
20
+ * Lines of a (draft) blockquote: one per paragraph, a pasted quote's own
21
+ * lines, and list items as `- ` lines.
22
+ */
23
+ function blockquoteLines(node, schema) {
24
+ const lines = [];
25
+ node.forEach(child => {
26
+ if (child.type.name === PROMPT_QUOTE_NODE) {
27
+ lines.push(String(child.attrs.text ?? ''));
28
+ }
29
+ else if (child.type.name === 'bulletList') {
30
+ lines.push(bulletListMarkdown(child, schema));
31
+ }
32
+ else if (child.isTextblock) {
33
+ lines.push(inlineText(child, schema));
34
+ }
35
+ else {
36
+ lines.push(blockquoteLines(child, schema));
37
+ }
38
+ });
39
+ return lines.join('\n');
40
+ }
41
+ /** Text of one block: textblocks inline, others by their own serializer. */
42
+ function blockText(node, schema) {
43
+ if (node.isTextblock)
44
+ return inlineText(node, schema);
45
+ const toText = textSerializers(schema)[node.type.name];
46
+ return toText ? toText({ node }) : node.textContent;
47
+ }
48
+ function bulletListMarkdown(list, schema, indent = '') {
49
+ const lines = [];
50
+ list.forEach(item => {
51
+ const itemLines = [];
52
+ let hasText = false;
53
+ item.forEach(child => {
54
+ if (child.type.name === 'bulletList') {
55
+ const nested = bulletListMarkdown(child, schema, `${indent} `);
56
+ if (nested)
57
+ itemLines.push(nested);
58
+ hasText ||= Boolean(nested);
59
+ return;
60
+ }
61
+ const text = blockText(child, schema);
62
+ hasText ||= Boolean(text.trim());
63
+ const prefix = itemLines.length ? `${indent} ` : `${indent}- `;
64
+ itemLines.push(text
65
+ .split('\n')
66
+ .map((line, i) => (i === 0 ? prefix + line : `${indent} ${line}`))
67
+ .join('\n'));
68
+ });
69
+ // An empty item is not text yet: `- ` alone must not count as a prompt.
70
+ if (hasText)
71
+ lines.push(...itemLines);
72
+ });
73
+ return lines.join('\n');
74
+ }
75
+ /** Only `- ` starts a list: `* ` and `+ ` stay text, as getText() writes `- `. */
76
+ const DashBulletList = BulletList.extend({
77
+ addInputRules() {
78
+ return [wrappingInputRule({ find: /^\s*-\s$/, type: this.type })];
79
+ },
80
+ });
81
+ /** The innermost of `names` the position is in, if any. */
82
+ function promptBlockAt($pos, names = ['listItem', 'blockquote']) {
83
+ for (let d = $pos.depth; d > 0; d -= 1) {
84
+ const name = $pos.node(d).type.name;
85
+ if (names.includes(name))
86
+ return name;
87
+ }
88
+ return null;
89
+ }
90
+ const inQuote = ($pos) => !!promptBlockAt($pos, ['blockquote']);
91
+ /**
92
+ * Locked quote: a read-only block holding the quoted text. It can be
93
+ * selected and deleted, not edited.
94
+ */
95
+ function createPromptQuoteNode(className) {
96
+ return Node.create({
97
+ name: PROMPT_QUOTE_NODE,
98
+ group: 'block',
99
+ atom: true,
100
+ selectable: true,
101
+ draggable: false,
102
+ addAttributes() {
103
+ return {
104
+ text: {
105
+ default: '',
106
+ parseHTML: el => el.textContent ?? '',
107
+ rendered: false,
108
+ },
109
+ };
110
+ },
111
+ parseHTML() {
112
+ return [{ tag: 'div[data-prompt-quote]' }];
113
+ },
114
+ renderHTML({ node }) {
115
+ return [
116
+ 'div',
117
+ {
118
+ 'data-prompt-quote': '',
119
+ contenteditable: 'false',
120
+ ...(className ? { class: className } : {}),
121
+ },
122
+ String(node.attrs.text ?? ''),
123
+ ];
124
+ },
125
+ renderText({ node }) {
126
+ return promptQuoteMarkdown(String(node.attrs.text ?? ''));
127
+ },
128
+ });
129
+ }
130
+ const lockQuotesKey = new PluginKey('promptLockQuotes');
131
+ /**
132
+ * Markdown-style lines in the prompt composer:
133
+ * - `- ` at the start of a line starts a bullet list (StarterKit's list);
134
+ * - `> ` starts a quote that stays editable while the caret is in it and
135
+ * turns into a read-only quote block once the caret leaves.
136
+ *
137
+ * `getText()` gives both back as markdown (`- item`, `> line`).
138
+ * Brings its own bullet list: use StarterKit with `bulletList: false` and
139
+ * `listItem` and `blockquote` enabled.
140
+ */
141
+ function createPromptBlocksExtension({ quoteClassName, listClassName, } = {}) {
142
+ const PromptBlocks = Extension.create({
143
+ name: 'promptBlocks',
144
+ extendNodeSchema(extension) {
145
+ if (extension.name === 'bulletList') {
146
+ return {
147
+ toText: ({ node }) => bulletListMarkdown(node, node.type.schema),
148
+ };
149
+ }
150
+ if (extension.name === 'blockquote') {
151
+ return {
152
+ toText: ({ node }) => promptQuoteMarkdown(blockquoteLines(node, node.type.schema)),
153
+ };
154
+ }
155
+ return {};
156
+ },
157
+ addProseMirrorPlugins() {
158
+ const { editor } = this;
159
+ return [
160
+ new Plugin({
161
+ key: lockQuotesKey,
162
+ appendTransaction: (transactions, oldState, state) => {
163
+ const docChanged = transactions.some(tr => tr.docChanged);
164
+ if (!docChanged && !transactions.some(tr => tr.selectionSet)) {
165
+ return null;
166
+ }
167
+ // A caret move can only leave a quote it was in.
168
+ if (!docChanged &&
169
+ !inQuote(oldState.selection.$from) &&
170
+ !inQuote(oldState.selection.$to)) {
171
+ return null;
172
+ }
173
+ const quoteType = state.schema.nodes[PROMPT_QUOTE_NODE];
174
+ const paragraphType = state.schema.nodes.paragraph;
175
+ if (!quoteType || !paragraphType)
176
+ return null;
177
+ const { from, to } = state.selection;
178
+ const drafts = [];
179
+ state.doc.descendants((node, pos) => {
180
+ // Quotes sit at the top level or in list items, never inline.
181
+ if (node.type.name !== 'blockquote')
182
+ return !node.isTextblock;
183
+ const end = pos + node.nodeSize;
184
+ // Only once the selection is fully outside: a range reaching
185
+ // into the quote (Shift+Arrow, drag, select all) keeps it.
186
+ if (to < pos || from > end)
187
+ drafts.push({ pos, node });
188
+ return false;
189
+ });
190
+ if (drafts.length === 0)
191
+ return null;
192
+ const tr = state.tr;
193
+ // Right to left, so earlier positions stay valid.
194
+ for (const { pos, node } of drafts.reverse()) {
195
+ const text = blockquoteLines(node, state.schema);
196
+ const replacement = text.trim()
197
+ ? quoteType.create({ text })
198
+ : paragraphType.create();
199
+ tr.replaceWith(pos, pos + node.nodeSize, replacement);
200
+ }
201
+ // In undo history with the edit that left the quote, so undo
202
+ // takes back the typing and the lock together.
203
+ return tr;
204
+ },
205
+ props: {
206
+ // StarterKit's `- ` / `> ` rules need the start of a paragraph;
207
+ // this also takes a line started with Shift+Enter (a hard break).
208
+ handleTextInput: (view, from, to, text) => {
209
+ if (text !== ' ' || from !== to || view.composing)
210
+ return false;
211
+ const { doc } = view.state;
212
+ if (from < 2)
213
+ return false;
214
+ const marker = doc.textBetween(from - 1, from);
215
+ if (marker !== '>' && marker !== '-')
216
+ return false;
217
+ const $marker = doc.resolve(from - 1);
218
+ if ($marker.parent.type.name !== 'paragraph')
219
+ return false;
220
+ if ($marker.nodeBefore?.type.name !== 'hardBreak')
221
+ return false;
222
+ const inBlock = promptBlockAt($marker);
223
+ // `> ` in a quote or list stays text; `- ` in a list is the next
224
+ // item, in a quote it starts a list there.
225
+ if (inBlock && marker === '>')
226
+ return false;
227
+ const start = (chain) => {
228
+ const run = chain.deleteRange({ from: from - 2, to: from });
229
+ if (inBlock === 'listItem')
230
+ return run.splitListItem('listItem');
231
+ run.splitBlock();
232
+ return marker === '>'
233
+ ? run.wrapIn('blockquote')
234
+ : run.toggleBulletList();
235
+ };
236
+ // Handled once the doc changed, even if a later step failed:
237
+ // the marker is gone, so the default insert would land wrong.
238
+ const before = view.state.doc;
239
+ start(editor.chain()).run();
240
+ return view.state.doc !== before;
241
+ },
242
+ },
243
+ }),
244
+ ];
245
+ },
246
+ });
247
+ return [
248
+ createPromptQuoteNode(quoteClassName),
249
+ DashBulletList.configure({
250
+ HTMLAttributes: listClassName ? { class: listClassName } : {},
251
+ }),
252
+ PromptBlocks,
253
+ ];
254
+ }
255
+ /** True when the caret is in a bullet list or a quote being typed. */
256
+ function isInPromptBlock(state) {
257
+ return promptBlockAt(state.selection.$from) !== null;
258
+ }
259
+
260
+ export { createPromptBlocksExtension, isInPromptBlock };
@@ -0,0 +1,143 @@
1
+ /** Node name of a locked (read-only) quote block. */
2
+ const PROMPT_QUOTE_NODE = 'promptQuote';
3
+ const QUOTE_LINE = /^>(?: (.*)|)$/;
4
+ const TOP_BULLET_LINE = /^- (.*)$/;
5
+ const BULLET_LINE = /^( *)- (.*)$/;
6
+ /** Indented line under a list item: a nested item or a continued line. */
7
+ const LIST_CHILD_LINE = /^ {2,}\S/;
8
+ /**
9
+ * Markdown quote of `text`: `> ` per line, `>` for a blank line, nothing for
10
+ * an empty quote (so `> ` alone does not count as a prompt).
11
+ */
12
+ function promptQuoteMarkdown(text) {
13
+ if (!text.trim())
14
+ return '';
15
+ return text
16
+ .split('\n')
17
+ .map(line => (line.trim() ? `> ${line}` : '>'))
18
+ .join('\n');
19
+ }
20
+ const textContent = (text) => text ? [{ type: 'text', text }] : [];
21
+ const paragraph = (text) => ({
22
+ type: 'paragraph',
23
+ content: textContent(text),
24
+ });
25
+ /** Bullet groups keep their raw lines (indents included) for buildList. */
26
+ function groupLines(lines) {
27
+ const groups = [];
28
+ for (const line of lines) {
29
+ const last = groups[groups.length - 1];
30
+ const quote = line.match(QUOTE_LINE);
31
+ const inList = last?.kind === 'bullet';
32
+ const bullet = !quote &&
33
+ (TOP_BULLET_LINE.test(line) || (inList && LIST_CHILD_LINE.test(line)));
34
+ const kind = quote ? 'quote' : bullet ? 'bullet' : 'plain';
35
+ const value = quote ? (quote[1] ?? '') : line;
36
+ if (last && last.kind === kind)
37
+ last.lines.push(value);
38
+ else
39
+ groups.push({ kind, lines: [value] });
40
+ }
41
+ return groups;
42
+ }
43
+ /**
44
+ * Bullet list from `- item` lines at `indent` spaces. A `- ` line two
45
+ * spaces deeper starts a nested list; other deeper lines continue the
46
+ * item's text (what getText() writes for a multi-line item).
47
+ */
48
+ function buildList(lines, indent = 0) {
49
+ const items = [];
50
+ for (const line of lines) {
51
+ const bullet = line.match(BULLET_LINE);
52
+ const item = items[items.length - 1];
53
+ if ((bullet && bullet[1].length === indent) || !item) {
54
+ items.push({ text: bullet ? bullet[2] : line.trim(), parts: [] });
55
+ continue;
56
+ }
57
+ const lineIndent = line.match(/^ */)[0].length;
58
+ const last = item.parts[item.parts.length - 1];
59
+ const startsNested = bullet && lineIndent === indent + 2;
60
+ if (startsNested || (last && 'nested' in last && lineIndent > indent + 2)) {
61
+ if (last && 'nested' in last)
62
+ last.nested.push(line);
63
+ else
64
+ item.parts.push({ nested: [line] });
65
+ continue;
66
+ }
67
+ const value = line.slice(Math.min(lineIndent, indent + 2));
68
+ // Text at the item's own indent continues it, after a nested list too.
69
+ if (!last)
70
+ item.text += `\n${value}`;
71
+ else if ('text' in last)
72
+ last.text += `\n${value}`;
73
+ else
74
+ item.parts.push({ text: value });
75
+ }
76
+ return {
77
+ type: 'bulletList',
78
+ content: items.map(item => ({
79
+ type: 'listItem',
80
+ content: [
81
+ paragraph(item.text),
82
+ ...item.parts.map(part => 'nested' in part
83
+ ? buildList(part.nested, indent + 2)
84
+ : paragraph(part.text)),
85
+ ],
86
+ })),
87
+ };
88
+ }
89
+ /**
90
+ * Blocks for plain `text` with `> ` lines as locked quotes and `- ` lines as
91
+ * a bullet list. `getText()` of the result gives `text` back (blocks are
92
+ * joined by a blank line, list items by a newline).
93
+ *
94
+ * Other lines stay in one paragraph with newlines, as the composer prefill
95
+ * always worked. A doc never ends on a quote or list: an empty line follows,
96
+ * so the caret can go there instead of selecting the read-only quote.
97
+ */
98
+ function promptBlocksFromText(text) {
99
+ const groups = groupLines(text.replace(/\r\n?/g, '\n').split('\n'));
100
+ const blocks = [];
101
+ groups.forEach((group, i) => {
102
+ if (group.kind === 'quote') {
103
+ // A bare `>` quotes nothing: keep it as an empty line.
104
+ if (!group.lines.join('').trim()) {
105
+ blocks.push(paragraph(''));
106
+ return;
107
+ }
108
+ blocks.push({
109
+ type: PROMPT_QUOTE_NODE,
110
+ attrs: { text: group.lines.join('\n') },
111
+ });
112
+ return;
113
+ }
114
+ if (group.kind === 'bullet') {
115
+ const list = buildList(group.lines);
116
+ // `- a`, blank line, `- b` is one list, as in markdown.
117
+ const prev = blocks[blocks.length - 1];
118
+ if (prev?.type === 'bulletList') {
119
+ prev.content = [...(prev.content ?? []), ...(list.content ?? [])];
120
+ }
121
+ else
122
+ blocks.push(list);
123
+ return;
124
+ }
125
+ // The blank line next to a quote or list is the block separator.
126
+ const lines = [...group.lines];
127
+ if (i > 0 && lines[0] === '')
128
+ lines.shift();
129
+ if (i < groups.length - 1 && lines[lines.length - 1] === '')
130
+ lines.pop();
131
+ if (lines.length === 0)
132
+ return;
133
+ blocks.push(paragraph(lines.join('\n')));
134
+ });
135
+ if (blocks[blocks.length - 1]?.type !== 'paragraph')
136
+ blocks.push(paragraph(''));
137
+ return blocks;
138
+ }
139
+ function promptBlocksDoc(text) {
140
+ return { type: 'doc', content: promptBlocksFromText(text) };
141
+ }
142
+
143
+ export { PROMPT_QUOTE_NODE, promptBlocksDoc, promptBlocksFromText, promptQuoteMarkdown };
@@ -1,3 +1,2 @@
1
1
  import type { JSONContent } from '@tiptap/core';
2
2
  export declare const PROMPT_COMPOSER_EMPTY_DOC: JSONContent;
3
- export declare function promptComposerParagraphDoc(text: string): JSONContent;
@@ -0,0 +1,25 @@
1
+ import { Extension, Node } from '@tiptap/core';
2
+ import type { ResolvedPos } from '@tiptap/pm/model';
3
+ export type CreatePromptBlocksExtensionOptions = {
4
+ /** Class of a locked quote and of a quote being typed. */
5
+ quoteClassName?: string;
6
+ /** Class of a bullet list. */
7
+ listClassName?: string;
8
+ };
9
+ /**
10
+ * Markdown-style lines in the prompt composer:
11
+ * - `- ` at the start of a line starts a bullet list (StarterKit's list);
12
+ * - `> ` starts a quote that stays editable while the caret is in it and
13
+ * turns into a read-only quote block once the caret leaves.
14
+ *
15
+ * `getText()` gives both back as markdown (`- item`, `> line`).
16
+ * Brings its own bullet list: use StarterKit with `bulletList: false` and
17
+ * `listItem` and `blockquote` enabled.
18
+ */
19
+ export declare function createPromptBlocksExtension({ quoteClassName, listClassName, }?: CreatePromptBlocksExtensionOptions): (Node<any, any> | Extension<any, any>)[];
20
+ /** True when the caret is in a bullet list or a quote being typed. */
21
+ export declare function isInPromptBlock(state: {
22
+ selection: {
23
+ $from: ResolvedPos;
24
+ };
25
+ }): boolean;
@@ -0,0 +1,3 @@
1
+ export { createPromptBlocksExtension, isInPromptBlock, } from './createPromptBlocksExtension';
2
+ export type { CreatePromptBlocksExtensionOptions } from './createPromptBlocksExtension';
3
+ export { PROMPT_QUOTE_NODE, promptBlocksDoc, promptBlocksFromText, promptQuoteMarkdown, } from './promptBlocksText';
@@ -0,0 +1,19 @@
1
+ import type { JSONContent } from '@tiptap/core';
2
+ /** Node name of a locked (read-only) quote block. */
3
+ export declare const PROMPT_QUOTE_NODE = "promptQuote";
4
+ /**
5
+ * Markdown quote of `text`: `> ` per line, `>` for a blank line, nothing for
6
+ * an empty quote (so `> ` alone does not count as a prompt).
7
+ */
8
+ export declare function promptQuoteMarkdown(text: string): string;
9
+ /**
10
+ * Blocks for plain `text` with `> ` lines as locked quotes and `- ` lines as
11
+ * a bullet list. `getText()` of the result gives `text` back (blocks are
12
+ * joined by a blank line, list items by a newline).
13
+ *
14
+ * Other lines stay in one paragraph with newlines, as the composer prefill
15
+ * always worked. A doc never ends on a quote or list: an empty line follows,
16
+ * so the caret can go there instead of selecting the read-only quote.
17
+ */
18
+ export declare function promptBlocksFromText(text: string): JSONContent[];
19
+ export declare function promptBlocksDoc(text: string): JSONContent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homecode/ui",
3
- "version": "5.20.19",
3
+ "version": "5.20.20",
4
4
  "description": "React UI components library",
5
5
  "scripts": {
6
6
  "tests": "jest",
@@ -61,6 +61,7 @@
61
61
  "dependencies": {
62
62
  "@tiptap/core": "^3.30.5",
63
63
  "@tiptap/extension-link": "^3.30.5",
64
+ "@tiptap/extension-list": "^3.30.5",
64
65
  "@tiptap/extension-mention": "^3.30.5",
65
66
  "@tiptap/extensions": "^3.30.5",
66
67
  "@tiptap/pm": "^3.30.5",