@homecode/ui 5.20.19 → 5.20.21

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 (23) hide show
  1. package/dist/esm/index.js +1 -1
  2. package/dist/esm/src/components/Autocomplete/Autocomplete.js +1 -0
  3. package/dist/esm/src/components/FormattedText/FormattedText.js +31 -5
  4. package/dist/esm/src/components/FormattedText/FormattedText.styl.js +2 -2
  5. package/dist/esm/src/components/InputFile/InputFile.js +1 -0
  6. package/dist/esm/src/components/PromptComposer/PromptComposer.styl.js +2 -2
  7. package/dist/esm/src/components/PromptComposer/promptComposerDoc.js +2 -13
  8. package/dist/esm/src/components/PromptComposer/promptComposerInsert.js +3 -2
  9. package/dist/esm/src/components/PromptComposer/promptComposerPlaceholder.js +10 -0
  10. package/dist/esm/src/components/PromptComposer/usePromptComposerEditor.js +18 -10
  11. package/dist/esm/src/tiptap/prompt-blocks/createPromptBlocksExtension.js +260 -0
  12. package/dist/esm/src/tiptap/prompt-blocks/promptBlocksText.js +143 -0
  13. package/dist/esm/types/src/components/FormattedText/FormattedText.d.ts +2 -1
  14. package/dist/esm/types/src/components/FormattedText/FormattedText.types.d.ts +10 -0
  15. package/dist/esm/types/src/components/FormattedText/index.d.ts +2 -2
  16. package/dist/esm/types/src/components/PromptComposer/promptComposerDoc.d.ts +0 -1
  17. package/dist/esm/types/src/components/PromptComposer/promptComposerPlaceholder.d.ts +9 -0
  18. package/dist/esm/types/src/components/PromptComposer/promptComposerPlaceholder.test.d.ts +1 -0
  19. package/dist/esm/types/src/tiptap/prompt-blocks/createPromptBlocksExtension.d.ts +25 -0
  20. package/dist/esm/types/src/tiptap/prompt-blocks/index.d.ts +3 -0
  21. package/dist/esm/types/src/tiptap/prompt-blocks/promptBlocks.test.d.ts +1 -0
  22. package/dist/esm/types/src/tiptap/prompt-blocks/promptBlocksText.d.ts +19 -0
  23. package/package.json +2 -1
package/dist/esm/index.js CHANGED
@@ -24,7 +24,7 @@ export { Flex } from './src/components/Flex/Flex.js';
24
24
  export { FoldedLine } from './src/components/FoldedLine/FoldedLine.js';
25
25
  export { FoldedLineAction, FoldedLineActionsProvider, useFoldedLineActionsRegister } from './src/components/FoldedLine/FoldedLineActionsContext.js';
26
26
  export { Form } from './src/components/Form/Form.js';
27
- export { FormattedText } from './src/components/FormattedText/FormattedText.js';
27
+ export { FormattedText, findJsonEndExclusive } from './src/components/FormattedText/FormattedText.js';
28
28
  export { Gap } from './src/components/Gap/Gap.js';
29
29
  export { Gallery, normalizeGalleryItem } from './src/components/Gallery/Gallery.js';
30
30
  export { GridLayout } from './src/components/GridLayout/GridLayout.js';
@@ -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';
@@ -108,7 +108,24 @@ function applyInjectors(text, injectors) {
108
108
  });
109
109
  return result;
110
110
  }
111
- function FormattedText({ text, className, onButtonClick, }) {
111
+ function FormattedText({ text, className, onButtonClick, quotes, injectors, breaks = true, }) {
112
+ // Without `<br>`s the text keeps its own newlines (pre-wrap), so a block, such
113
+ // as a table, swallows the blank lines after it instead of showing them.
114
+ const blankLinesAfter = (content, from) => breaks ? 0 : content.slice(from).match(/^\s*/)[0].length;
115
+ /** A run of `> ` lines, grayed with a bar down its left side. */
116
+ const injectQuote = (content) => {
117
+ const matches = content.match(/(?:^|\n)(>[^\n]*(?:\n>[^\n]*)*)(?:\n|$)/);
118
+ if (!matches)
119
+ return null;
120
+ const lines = matches[1]
121
+ .split('\n')
122
+ .map(line => line.replace(/^>[ \t]?/, ''));
123
+ return {
124
+ elem: (jsx("div", { className: S.quote, "data-quote": true, children: lines.join('\n') })),
125
+ index: matches.index,
126
+ length: matches[0].length,
127
+ };
128
+ };
112
129
  const injectLines = (content) => {
113
130
  const matches = content.match(/(^|\n)---[ \t]*(?:\n|$)/);
114
131
  if (!matches)
@@ -266,6 +283,7 @@ function FormattedText({ text, className, onButtonClick, }) {
266
283
  const headingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
267
284
  const processCellContent = (value, depth = 0) => applyInjectors(value, [
268
285
  injectCodeBlock,
286
+ ...(injectors ?? []),
269
287
  injectPrettyJson,
270
288
  ...(depth === 0
271
289
  ? [
@@ -291,7 +309,10 @@ function FormattedText({ text, className, onButtonClick, }) {
291
309
  injectButtons,
292
310
  ]);
293
311
  const injectHeading = (content) => {
294
- const matches = content.match(/(^|\n)(#{1,6})\s+([^\n]+)/);
312
+ // Without `<br>`s the text keeps its own newlines (pre-wrap), so a heading,
313
+ // which is a block, swallows the blank lines around it instead of leaving
314
+ // an extra empty line.
315
+ const matches = content.match(breaks ? /(^|\n)(#{1,6})\s+([^\n]+)/ : /(^|\n+)(#{1,6})[ \t]+([^\n]+)\n*/);
295
316
  if (!matches)
296
317
  return null;
297
318
  const Tag = headingTags[Math.min(matches[2].length, 6) - 1];
@@ -350,13 +371,18 @@ function FormattedText({ text, className, onButtonClick, }) {
350
371
  return {
351
372
  elem: (jsx(Table, { size: "s", variant: "plain", columns: columns, data: tableData, className: S.table })),
352
373
  index: matches.index + (matches[1]?.length || 0),
353
- length: matches[0].length - (matches[1]?.length || 0),
374
+ length: matches[0].length -
375
+ (matches[1]?.length || 0) +
376
+ blankLinesAfter(content, matches.index + matches[0].length),
354
377
  };
355
378
  };
356
379
  return (jsx("div", { className: className, children: applyInjectors(text, [
357
380
  injectTables,
358
381
  injectCodeBlock,
382
+ ...(injectors ?? []),
359
383
  injectPrettyJson,
384
+ // After code, so a `> ` line inside a code block stays code.
385
+ ...(quotes ? [injectQuote] : []),
360
386
  injectHeading,
361
387
  injectLines,
362
388
  injectLinks,
@@ -365,8 +391,8 @@ function FormattedText({ text, className, onButtonClick, }) {
365
391
  injectBullet,
366
392
  injectNumbered,
367
393
  injectButtons,
368
- injectBreaks,
394
+ ...(breaks ? [injectBreaks] : []),
369
395
  ]) }));
370
396
  }
371
397
 
372
- export { FormattedText, hrefForFormattedLink };
398
+ export { FormattedText, findJsonEndExclusive, hrefForFormattedLink };
@@ -1,5 +1,5 @@
1
- var css_248z = ".FormattedText_bullet__vbqlq{display:inline-block;margin-bottom:2px;margin-left:var(--p-2);margin-right:2px}.FormattedText_bullet__vbqlq:before{background-color:var(--accent-color);border-radius:50%;content:\"\";display:block;height:6px;margin:0 4px;width:6px}.FormattedText_numberedIndex__rZ6Kt{font-feature-settings:\"tnum\";color:var(--accent-color);display:inline-block;font-variant-numeric:tabular-nums;margin-left:var(--p-2);margin-right:6px;min-width:1.25em}.FormattedText_line__c2bmE{background-color:var(--accent-color-alpha-200);border-radius:2px;height:2px;margin:var(--p-2) 0;width:100%}.FormattedText_codeBlock__Xrbvn,.FormattedText_codeInline__jCbNL{background-color:var(--decent-color-alpha-400);border-radius:var(--p-2);box-shadow:0 1px 1px var(--accent-color-alpha-50),inset 0 1px 1px var(--decent-color-alpha-500);font-family:monospace;-webkit-user-select:text;-moz-user-select:text;user-select:text}.FormattedText_codeInline__jCbNL{border-radius:3px;display:inline;font-size:90%;padding:0 4px}.FormattedText_codeBlock__Xrbvn,.FormattedText_jsonPre__qOGmo{display:block;font-size:90%;margin:var(--p-2) 0;overflow-x:auto;padding:var(--p-2);white-space:pre-wrap}.FormattedText_jsonPre__qOGmo{background-color:var(--decent-color-alpha-300);border-radius:var(--p-2);box-shadow:0 1px 1px var(--accent-color-alpha-50),inset 0 1px 1px var(--accent-color-alpha-50);font-family:monospace;max-width:100%;-webkit-user-select:text;-moz-user-select:text;user-select:text}.FormattedText_table__ipMMS{margin-bottom:10px}";
2
- var S = {"bullet":"FormattedText_bullet__vbqlq","numberedIndex":"FormattedText_numberedIndex__rZ6Kt","line":"FormattedText_line__c2bmE","codeInline":"FormattedText_codeInline__jCbNL","codeBlock":"FormattedText_codeBlock__Xrbvn","jsonPre":"FormattedText_jsonPre__qOGmo","table":"FormattedText_table__ipMMS"};
1
+ var css_248z = ".FormattedText_bullet__vbqlq{display:inline-block;margin-bottom:2px;margin-left:var(--p-2);margin-right:2px}.FormattedText_bullet__vbqlq:before{background-color:var(--accent-color);border-radius:50%;content:\"\";display:block;height:6px;margin:0 4px;width:6px}.FormattedText_numberedIndex__rZ6Kt{font-feature-settings:\"tnum\";color:var(--accent-color);display:inline-block;font-variant-numeric:tabular-nums;margin-left:var(--p-2);margin-right:6px;min-width:1.25em}.FormattedText_quote__BBm-M{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}.FormattedText_quote__BBm-M:before{background-color:var(--accent-color);border-radius:2px;bottom:2px;content:\"\";left:2px;opacity:.3;position:absolute;top:2px;width:4px}.FormattedText_line__c2bmE{background-color:var(--accent-color-alpha-200);border-radius:2px;height:2px;margin:var(--p-2) 0;width:100%}.FormattedText_codeBlock__Xrbvn,.FormattedText_codeInline__jCbNL{background-color:var(--decent-color-alpha-400);border-radius:var(--p-2);box-shadow:0 1px 1px var(--accent-color-alpha-50),inset 0 1px 1px var(--decent-color-alpha-500);font-family:monospace;-webkit-user-select:text;-moz-user-select:text;user-select:text}.FormattedText_codeInline__jCbNL{border-radius:3px;display:inline;font-size:90%;padding:0 4px}.FormattedText_codeBlock__Xrbvn,.FormattedText_jsonPre__qOGmo{display:block;font-size:90%;margin:var(--p-2) 0;overflow-x:auto;padding:var(--p-2);white-space:pre-wrap}.FormattedText_jsonPre__qOGmo{background-color:var(--decent-color-alpha-300);border-radius:var(--p-2);box-shadow:0 1px 1px var(--accent-color-alpha-50),inset 0 1px 1px var(--accent-color-alpha-50);font-family:monospace;max-width:100%;-webkit-user-select:text;-moz-user-select:text;user-select:text}.FormattedText_table__ipMMS{margin-bottom:10px}";
2
+ var S = {"bullet":"FormattedText_bullet__vbqlq","numberedIndex":"FormattedText_numberedIndex__rZ6Kt","quote":"FormattedText_quote__BBm-M","line":"FormattedText_line__c2bmE","codeInline":"FormattedText_codeInline__jCbNL","codeBlock":"FormattedText_codeBlock__Xrbvn","jsonPre":"FormattedText_jsonPre__qOGmo","table":"FormattedText_table__ipMMS"};
3
3
  if (typeof document !== 'undefined') {
4
4
  var __uilibHead = document.head || document.getElementsByTagName('head')[0];
5
5
  if (__uilibHead) {
@@ -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
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Placeholder text for the composer's empty paragraphs. Only a wholly empty
3
+ * composer shows it: a quote or list with an empty line after it already is
4
+ * content, so it keeps no hint.
5
+ */
6
+ function promptComposerPlaceholder(text) {
7
+ return ({ editor }) => (editor.isEmpty ? text : '');
8
+ }
9
+
10
+ export { promptComposerPlaceholder };
@@ -3,12 +3,15 @@ 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 { promptComposerPlaceholder } from './promptComposerPlaceholder.js';
7
+ import { createPromptBlocksExtension, isInPromptBlock } from '../../tiptap/prompt-blocks/createPromptBlocksExtension.js';
8
+ import { promptBlocksDoc } from '../../tiptap/prompt-blocks/promptBlocksText.js';
6
9
  import { createLinkHighlightExtension } from '../../tiptap/link-highlight/createLinkHighlightExtension.js';
7
10
  import { NonInclusiveLink } from '../../tiptap/link-highlight/nonInclusiveLink.js';
8
11
  import { DEFAULT_CHAT_SLASH_ITEMS } from '../../tiptap/slash-mention/defaultChatSlashItems.js';
9
12
  import { createSlashMentionExtension } from '../../tiptap/slash-mention/createSlashMentionExtension.js';
10
13
  import { safePromptComposerEditorText, promptComposerSafeEditorDom, syncPromptComposerHeight } from './PromptComposer.helpers.js';
11
- import { PROMPT_COMPOSER_EMPTY_DOC, promptComposerParagraphDoc } from './promptComposerDoc.js';
14
+ import { PROMPT_COMPOSER_EMPTY_DOC } from './promptComposerDoc.js';
12
15
 
13
16
  function buildMentionConfigs({ slashCommandItems, onSlashItemCommand, mentionConfigs, atMentionItems, atMentionGetItems, onAtItemCommand, }) {
14
17
  const configs = [];
@@ -101,18 +104,22 @@ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, sla
101
104
  heading: false,
102
105
  bulletList: false,
103
106
  orderedList: false,
104
- blockquote: false,
107
+ blockquote: { HTMLAttributes: { class: S.promptComposerQuote } },
105
108
  codeBlock: false,
106
109
  horizontalRule: false,
107
110
  link: false,
108
111
  }),
109
112
  NonInclusiveLink,
110
113
  Placeholder.configure({
111
- placeholder: placeholderText,
114
+ placeholder: promptComposerPlaceholder(placeholderText),
112
115
  showOnlyWhenEditable: true,
113
116
  emptyNodeClass: S.promptComposerEmptyNode,
114
117
  }),
115
118
  createLinkHighlightExtension(S.promptComposerLink),
119
+ ...createPromptBlocksExtension({
120
+ quoteClassName: S.promptComposerQuote,
121
+ listClassName: S.promptComposerList,
122
+ }),
116
123
  ];
117
124
  for (const cfg of resolvedMentionConfigs) {
118
125
  const slashChar = cfg.slashChar;
@@ -180,7 +187,8 @@ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, sla
180
187
  if (!activeEditor)
181
188
  return false;
182
189
  const currentText = activeEditor.getText();
183
- const hasNewlines = currentText.includes('\n');
190
+ // In a list or a quote being typed, Enter adds the next line.
191
+ const hasNewlines = currentText.includes('\n') || isInPromptBlock(activeEditor.state);
184
192
  const isModEnter = event.metaKey || event.ctrlKey;
185
193
  // Single line: Enter submits. Multiline: Enter inserts a line; Cmd/Ctrl+Enter submits.
186
194
  if (hasNewlines ? !isModEnter : isModEnter)
@@ -249,13 +257,13 @@ function usePromptComposerEditor({ disabled, placeholder, slashCommandItems, sla
249
257
  const next = prefillMessage;
250
258
  if (current === next)
251
259
  return;
260
+ // The app trims the prompt on blur, and a list or quote at the end always
261
+ // has an empty line after it. Resetting for that would refocus the editor
262
+ // and turn an empty last item into text.
263
+ if (next !== '' && next === current.trim())
264
+ return;
252
265
  try {
253
- if (next === '') {
254
- editor.commands.setContent(PROMPT_COMPOSER_EMPTY_DOC);
255
- }
256
- else {
257
- editor.commands.setContent(promptComposerParagraphDoc(next));
258
- }
266
+ editor.commands.setContent(promptBlocksDoc(next));
259
267
  }
260
268
  catch {
261
269
  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,4 +1,5 @@
1
1
  import React from 'react';
2
2
  import type { FormattedTextProps } from './FormattedText.types';
3
3
  export declare function hrefForFormattedLink(url: string): string;
4
- export declare function FormattedText({ text, className, onButtonClick, }: FormattedTextProps): React.JSX.Element;
4
+ export declare function findJsonEndExclusive(content: string, start: number): number;
5
+ export declare function FormattedText({ text, className, onButtonClick, quotes, injectors, breaks, }: FormattedTextProps): React.JSX.Element;
@@ -1,3 +1,10 @@
1
+ import type React from 'react';
2
+ export type InjectionResult = {
3
+ elem: React.ReactNode;
4
+ index: number;
5
+ length: number;
6
+ } | null;
7
+ export type Injector = (content: string) => InjectionResult;
1
8
  export type FormattedTextProps = {
2
9
  text: string;
3
10
  className?: string;
@@ -5,4 +12,7 @@ export type FormattedTextProps = {
5
12
  text: string;
6
13
  [name: string]: string;
7
14
  }) => void;
15
+ quotes?: boolean;
16
+ injectors?: Injector[];
17
+ breaks?: boolean;
8
18
  };
@@ -1,2 +1,2 @@
1
- export { FormattedText } from './FormattedText';
2
- export type { FormattedTextProps } from './FormattedText.types';
1
+ export { FormattedText, findJsonEndExclusive } from './FormattedText';
2
+ export type { FormattedTextProps, InjectionResult, Injector, } from './FormattedText.types';
@@ -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,9 @@
1
+ import type { Editor } from '@tiptap/core';
2
+ /**
3
+ * Placeholder text for the composer's empty paragraphs. Only a wholly empty
4
+ * composer shows it: a quote or list with an empty line after it already is
5
+ * content, so it keeps no hint.
6
+ */
7
+ export declare function promptComposerPlaceholder(text: string): ({ editor }: {
8
+ editor: Editor;
9
+ }) => string;
@@ -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.21",
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",