@pipe0/react 1.0.22 → 1.0.24

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @pipe0/elements-react
2
2
 
3
+ ## 1.0.24
4
+
5
+ ### Patch Changes
6
+
7
+ - 1246c12: Add email stream
8
+ - Updated dependencies [1246c12]
9
+ - @pipe0/base@1.0.24
10
+
11
+ ## 1.0.23
12
+
13
+ ### Patch Changes
14
+
15
+ - a412da6: Deprecate crustdata@2
16
+ - Updated dependencies [a412da6]
17
+ - @pipe0/base@1.0.23
18
+
3
19
  ## 1.0.22
4
20
 
5
21
  ### Patch Changes
@@ -4,6 +4,7 @@ import { useAsyncRemoteSource } from "../../../hooks/use-async-remote-source.mjs
4
4
  import { SuggestionMenu } from "../suggestion-menu/suggestion-menu.mjs";
5
5
  import { TagChipDecoration } from "../tag-chip-decoration.mjs";
6
6
  import { ChipEditPopover } from "./ChipEditPopover.mjs";
7
+ import { PLAIN_TEXT_STARTER_KIT, liquidBlockSeparator, textToLiquidDoc } from "./liquid-document.mjs";
7
8
  import { UnifiedReferencePicker, buildReferenceItems, parseQuery } from "./UnifiedReferencePicker.mjs";
8
9
  import { useCallback, useEffect, useMemo, useState } from "react";
9
10
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -12,24 +13,6 @@ import { EditorContent, EditorContext, useEditor } from "@tiptap/react";
12
13
  import StarterKit from "@tiptap/starter-kit";
13
14
 
14
15
  //#region src/components/internal/LiquidEditor/LiquidEditor.tsx
15
- function escapeHtml(s) {
16
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
17
- }
18
- /**
19
- * Reconciles single- and multi-line text into Tiptap-friendly HTML.
20
- * - Single-line: collapses whitespace runs containing newlines into a
21
- * single space and renders as one paragraph.
22
- * - Multi-line: paragraphs separated by blank lines (`\n\n+`); soft
23
- * breaks within a paragraph render as `<br>`. Leading spaces on each
24
- * line are preserved with `&nbsp;` so JSON-shaped payloads round-trip
25
- * through the editor.
26
- */
27
- function textToTiptapHTML(text, multiline) {
28
- if (!multiline) return `<p>${escapeHtml(text.replace(/\s*\n+\s*/g, " "))}</p>`;
29
- return escapeHtml(text).split(/\n\n+/).map((block) => {
30
- return `<p>${block.split(/\n/).map((line) => line.replace(/^ +/, (spaces) => "&nbsp;".repeat(spaces.length))).join("<br>")}</p>`;
31
- }).join("");
32
- }
33
16
  const OUTPUT_TEMPLATE = (fieldType) => {
34
17
  if (fieldType === "json") return `{% output FIELD_NAME, type: "json", schema: "SCHEMA_NAME", description: "" %}`;
35
18
  return `{% output FIELD_NAME, type: "${fieldType}", description: "" %}`;
@@ -49,12 +32,13 @@ const OUTPUT_TEMPLATE = (fieldType) => {
49
32
  * Chips render via the `TagChipDecoration` extension. Click or
50
33
  * Enter/Space on a chip mounts the contextual `ChipEditPopover`.
51
34
  *
52
- * Backwards compat: the persisted value is plain Liquid text,
53
- * byte-identical to what the legacy `TagEditor` / `TextPromptEditor`
54
- * produced. No engine, analyzer, or metadata changes.
35
+ * The persisted value is plain Liquid text. `liquid-document.ts` owns the
36
+ * text ⇄ document mapping and keeps it a bijection — the document model is
37
+ * deliberately no richer than the flat string it serializes back to, so a
38
+ * save can never drop characters. No engine, analyzer, or metadata changes.
55
39
  */
56
40
  function LiquidEditor({ value, onChange, inputFields, searchSecrets, searchConstants, multiline = false, autoGrow = true, directives = [], expectedFieldType, placeholder, className, editorProps, hideLegend = false }) {
57
- const blockSeparator = multiline ? "\n" : "";
41
+ const blockSeparator = liquidBlockSeparator(multiline);
58
42
  const supportsOutput = directives.includes("output");
59
43
  const hasSecretsResolver = !!searchSecrets;
60
44
  const hasConstantsResolver = !!searchConstants;
@@ -77,12 +61,15 @@ function LiquidEditor({ value, onChange, inputFields, searchSecrets, searchConst
77
61
  });
78
62
  } }), []);
79
63
  const editor = useEditor({
80
- extensions: [StarterKit.configure({ hardBreak: !multiline ? false : void 0 }), tagChipExtension],
64
+ extensions: [StarterKit.configure({
65
+ ...PLAIN_TEXT_STARTER_KIT,
66
+ hardBreak: multiline ? void 0 : false
67
+ }), tagChipExtension],
81
68
  parseOptions: { preserveWhitespace: "full" },
82
69
  editorProps: {
83
70
  ...editorProps,
84
71
  attributes: {
85
- class: cn("pz:px-2.5 pz:py-1 pz:text-sm pz:outline-none pz:break-words", multiline ? autoGrow ? "pz:min-h-24 pz:max-h-96 pz:overflow-auto pz:whitespace-pre-wrap" : "pz:overflow-auto pz:whitespace-pre-wrap" : "pz:min-h-8", className),
72
+ class: cn("pz:px-2.5 pz:py-1 pz:text-sm pz:outline-none pz:break-words", multiline ? autoGrow ? "pz:min-h-24 pz:max-h-96 pz:overflow-auto pz:whitespace-pre-wrap" : "pz:overflow-auto pz:whitespace-pre-wrap" : "pz:min-h-8 pz:whitespace-pre-wrap", className),
86
73
  ...placeholder ? { "data-placeholder": placeholder } : {},
87
74
  ...editorProps?.attributes
88
75
  },
@@ -92,7 +79,7 @@ function LiquidEditor({ value, onChange, inputFields, searchSecrets, searchConst
92
79
  return false;
93
80
  }
94
81
  },
95
- content: textToTiptapHTML(value || "", multiline),
82
+ content: textToLiquidDoc(value || "", multiline),
96
83
  onUpdate({ editor }) {
97
84
  onChange(editor.getText({ blockSeparator }));
98
85
  }
@@ -100,7 +87,7 @@ function LiquidEditor({ value, onChange, inputFields, searchSecrets, searchConst
100
87
  useEffect(() => {
101
88
  if (!editor) return;
102
89
  if (editor.getText({ blockSeparator }) === (value ?? "")) return;
103
- editor.commands.setContent(textToTiptapHTML(value ?? "", multiline), { emitUpdate: false });
90
+ editor.commands.setContent(textToLiquidDoc(value ?? "", multiline), { emitUpdate: false });
104
91
  }, [
105
92
  editor,
106
93
  value,
@@ -1 +1 @@
1
- {"version":3,"file":"LiquidEditor.mjs","names":[],"sources":["../../../../src/components/internal/LiquidEditor/LiquidEditor.tsx"],"sourcesContent":["import {\n type PipesFieldDefinitionWithName,\n RECORD_FIELD_TYPES,\n type RecordFieldType,\n} from \"@pipe0/base\";\nimport type { EditorProps } from \"@tiptap/pm/view\";\nimport { EditorContent, EditorContext, useEditor } from \"@tiptap/react\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useAsyncRemoteSource } from \"../../../hooks/use-async-remote-source.js\";\nimport { cn } from \"../../../lib/utils.js\";\nimport type { ConstantSuggestion, SecretSuggestion } from \"../../../types/field-props.js\";\nimport { FieldLegend, type LegendEntry } from \"../field-legend.js\";\nimport { SuggestionMenu } from \"../suggestion-menu/suggestion-menu.js\";\nimport type { SuggestionItem } from \"../suggestion-menu/suggestion-menu-types.js\";\nimport { type ChipHit, TagChipDecoration } from \"../tag-chip-decoration.js\";\nimport { ChipEditPopover, type ChipEditTarget } from \"./ChipEditPopover.js\";\nimport {\n buildReferenceItems,\n parseQuery,\n type ReferenceContext,\n SECTION_CAP,\n UnifiedReferencePicker,\n} from \"./UnifiedReferencePicker.js\";\n\nexport type DirectiveKind = \"output\";\n\nexport interface LiquidEditorProps {\n value: string;\n onChange: (v: string) => void;\n inputFields: PipesFieldDefinitionWithName[];\n /**\n * Per-keystroke async searcher for secrets. Called from the `/` reference\n * picker as the user types. Calls are debounced and race-guarded — only\n * the response for the latest query is rendered.\n */\n searchSecrets?: (query: string) => Promise<SecretSuggestion[]>;\n /**\n * Per-keystroke async searcher for constants. Mirrors `searchSecrets`.\n */\n searchConstants?: (query: string) => Promise<ConstantSuggestion[]>;\n multiline?: boolean;\n /**\n * When true, the editor renders within `min-h`/`max-h` bounds. When\n * false, callers control the height via `className`. Default: true.\n */\n autoGrow?: boolean;\n directives?: DirectiveKind[];\n expectedFieldType?: RecordFieldType;\n placeholder?: string;\n className?: string;\n editorProps?: EditorProps;\n /**\n * Suppress the per-editor legend. Used by `loose_object_input` which\n * renders a single legend below the entire list rather than per-cell.\n */\n hideLegend?: boolean;\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n}\n\n/**\n * Reconciles single- and multi-line text into Tiptap-friendly HTML.\n * - Single-line: collapses whitespace runs containing newlines into a\n * single space and renders as one paragraph.\n * - Multi-line: paragraphs separated by blank lines (`\\n\\n+`); soft\n * breaks within a paragraph render as `<br>`. Leading spaces on each\n * line are preserved with `&nbsp;` so JSON-shaped payloads round-trip\n * through the editor.\n */\nfunction textToTiptapHTML(text: string, multiline: boolean): string {\n if (!multiline) {\n return `<p>${escapeHtml(text.replace(/\\s*\\n+\\s*/g, \" \"))}</p>`;\n }\n return escapeHtml(text)\n .split(/\\n\\n+/)\n .map((block) => {\n const lines = block.split(/\\n/);\n const htmlLines = lines\n .map((line) => line.replace(/^ +/, (spaces) => \"&nbsp;\".repeat(spaces.length)))\n .join(\"<br>\");\n return `<p>${htmlLines}</p>`;\n })\n .join(\"\");\n}\n\nconst OUTPUT_TEMPLATE = (fieldType: RecordFieldType): string => {\n if (fieldType === \"json\") {\n return `{% output FIELD_NAME, type: \"json\", schema: \"SCHEMA_NAME\", description: \"\" %}`;\n }\n return `{% output FIELD_NAME, type: \"${fieldType}\", description: \"\" %}`;\n};\n\n/**\n * Single editor surface for every form field that accepts Liquid tags.\n * Two triggers, two cognitive categories:\n *\n * - `/` opens a unified reference picker (Fields, Secrets, Constants).\n * Prefix syntax `/f/`, `/s/`, `/c/` filters to one source. Picking\n * emits the corresponding `{{ … }}` Liquid Output.\n *\n * - `@` opens a directives picker (only when `directives.length > 0`).\n * Today the only directive is `output`, which inserts a templated\n * `{% output FIELD_NAME, type: \"...\", description: \"\" %}` block.\n *\n * Chips render via the `TagChipDecoration` extension. Click or\n * Enter/Space on a chip mounts the contextual `ChipEditPopover`.\n *\n * Backwards compat: the persisted value is plain Liquid text,\n * byte-identical to what the legacy `TagEditor` / `TextPromptEditor`\n * produced. No engine, analyzer, or metadata changes.\n */\nexport function LiquidEditor({\n value,\n onChange,\n inputFields,\n searchSecrets,\n searchConstants,\n multiline = false,\n autoGrow = true,\n directives = [],\n expectedFieldType,\n placeholder,\n className,\n editorProps,\n hideLegend = false,\n}: LiquidEditorProps) {\n const blockSeparator = multiline ? \"\\n\" : \"\";\n const supportsOutput = directives.includes(\"output\");\n const hasSecretsResolver = !!searchSecrets;\n const hasConstantsResolver = !!searchConstants;\n\n const secretSource = useAsyncRemoteSource<SecretSuggestion>({ search: searchSecrets });\n const constantSource = useAsyncRemoteSource<ConstantSuggestion>({ search: searchConstants });\n\n // Warm caches once per resolver identity so the empty-query open is hot.\n useEffect(() => {\n secretSource.prefetch();\n }, [secretSource.prefetch]);\n\n useEffect(() => {\n constantSource.prefetch();\n }, [constantSource.prefetch]);\n\n const [chipTarget, setChipTarget] = useState<ChipEditTarget | null>(null);\n\n const tagChipExtension = useMemo(\n () =>\n TagChipDecoration.configure({\n onChipClick: (hit: ChipHit, el: HTMLElement) => {\n setChipTarget({\n kind: hit.kind,\n raw: hit.raw,\n from: hit.from,\n to: hit.to,\n rect: el.getBoundingClientRect(),\n });\n },\n }),\n [],\n );\n\n const editor = useEditor({\n extensions: [\n StarterKit.configure({ hardBreak: !multiline ? false : undefined }),\n tagChipExtension,\n ],\n parseOptions: { preserveWhitespace: \"full\" },\n editorProps: {\n ...editorProps,\n attributes: {\n class: cn(\n \"pz:px-2.5 pz:py-1 pz:text-sm pz:outline-none pz:break-words\",\n multiline\n ? autoGrow\n ? \"pz:min-h-24 pz:max-h-96 pz:overflow-auto pz:whitespace-pre-wrap\"\n : \"pz:overflow-auto pz:whitespace-pre-wrap\"\n : \"pz:min-h-8\",\n className,\n ),\n ...(placeholder ? { \"data-placeholder\": placeholder } : {}),\n ...editorProps?.attributes,\n },\n handleKeyDown(view, event) {\n if (editorProps?.handleKeyDown?.(view, event)) return true;\n // Single-line: Enter must not split the document. Multi-line: let\n // StarterKit handle Enter so paragraphs and blocks behave normally.\n if (!multiline && event.key === \"Enter\") return true;\n return false;\n },\n },\n content: textToTiptapHTML(value || \"\", multiline),\n onUpdate({ editor }) {\n onChange(editor.getText({ blockSeparator }));\n },\n });\n\n // Sync the editor's document when `value` changes from outside (e.g.\n // form reset, programmatic setValue). `useEditor` only reads `content`\n // at mount; without this hook the visible UI drifts from form state.\n // The early-return short-circuits the loop that the editor's own\n // `onUpdate` would otherwise create.\n useEffect(() => {\n if (!editor) return;\n const current = editor.getText({ blockSeparator });\n if (current === (value ?? \"\")) return;\n editor.commands.setContent(textToTiptapHTML(value ?? \"\", multiline), {\n emitUpdate: false,\n });\n }, [editor, value, multiline, blockSeparator]);\n\n const insertField = useCallback((name: string) => `{{ ${name} }}`, []);\n\n // Tracks the current query in the open reference picker. Drives the\n // `liveItems` recomputation below — when the async sources resolve, we\n // re-render the picker against this query without waiting for Tiptap to\n // re-call items().\n const [referenceQuery, setReferenceQuery] = useState(\"\");\n\n // In sectioned (empty-query) view, cap each kind at SECTION_CAP visible\n // rows. Users see a compact summary and type to filter; the act of\n // filtering switches to flat mode where all matches are shown.\n // Caps are applied at this layer (not inside the picker) so the array\n // passed to SuggestionMenu matches what's rendered — keyboard nav\n // operates on the visible set and never lands on a hidden row.\n const buildVisibleItems = useCallback(\n (\n query: string,\n ): {\n items: SuggestionItem<ReferenceContext>[];\n overflow: Record<ReferenceContext[\"kind\"], number>;\n } => {\n const all = buildReferenceItems(\n query,\n {\n inputFields,\n secretSuggestions: secretSource.items,\n constantSuggestions: constantSource.items,\n },\n expectedFieldType,\n insertField,\n );\n const { prefix, residual } = parseQuery(query);\n const isFlat = !!residual.trim() || prefix !== null;\n if (isFlat) {\n return { items: all, overflow: { field: 0, secret: 0, constant: 0 } };\n }\n const fields = all.filter((i) => i.context?.kind === \"field\");\n const secrets = all.filter((i) => i.context?.kind === \"secret\");\n const constants = all.filter((i) => i.context?.kind === \"constant\");\n return {\n items: [\n ...fields.slice(0, SECTION_CAP),\n ...secrets.slice(0, SECTION_CAP),\n ...constants.slice(0, SECTION_CAP),\n ],\n overflow: {\n field: Math.max(0, fields.length - SECTION_CAP),\n secret: Math.max(0, secrets.length - SECTION_CAP),\n constant: Math.max(0, constants.length - SECTION_CAP),\n },\n };\n },\n [inputFields, expectedFieldType, insertField, secretSource.items, constantSource.items],\n );\n\n const referenceItems = useCallback(\n ({ query }: { query: string }): SuggestionItem<ReferenceContext>[] => {\n setReferenceQuery(query);\n // Fire-and-forget: results land via React state in `secretSource.items`\n // / `constantSource.items` and trigger a re-render. The popup reads\n // them through `liveItems` (see below).\n secretSource.ensure(query);\n constantSource.ensure(query);\n return buildVisibleItems(query).items;\n },\n [secretSource.ensure, constantSource.ensure, buildVisibleItems],\n );\n\n // Live items derived from the current query + cached source items. This is\n // recomputed whenever an async source resolves (which updates\n // `secretSource.items` / `constantSource.items` via setState). Passed to\n // SuggestionMenu via `liveItems` so the open popup re-renders without\n // needing the user to type — Tiptap's items() callback alone cannot\n // deliver async results that arrive between keystrokes.\n const { items: liveReferenceItems, overflow: liveOverflow } = useMemo(\n () => buildVisibleItems(referenceQuery),\n [referenceQuery, buildVisibleItems],\n );\n\n const directiveItems = useCallback(\n () =>\n RECORD_FIELD_TYPES.map<SuggestionItem>((fieldType) => ({\n title: `${fieldType}`,\n keywords: [fieldType, \"output\"],\n onSelect: ({ editor: ed, range }) => {\n ed.chain().focus().insertContentAt(range, OUTPUT_TEMPLATE(fieldType)).run();\n },\n })),\n [],\n );\n\n const legendEntries: LegendEntry[] = [];\n legendEntries.push({ key: \"/\", label: \"to insert a reference\" });\n if (supportsOutput) legendEntries.push({ key: \"@\", label: \"to add an output field\" });\n\n return (\n <EditorContext.Provider value={{ editor }}>\n <EditorContent editor={editor} />\n\n {!hideLegend && <FieldLegend entries={legendEntries} />}\n\n <SuggestionMenu\n editor={editor}\n char=\"/\"\n pluginKey=\"liquidEditorReferencePicker\"\n items={referenceItems}\n liveItems={liveReferenceItems}\n >\n {(props) => (\n <UnifiedReferencePicker\n items={props.items as SuggestionItem<ReferenceContext>[]}\n selectedIndex={props.selectedIndex}\n onSelect={props.onSelect as (item: SuggestionItem<ReferenceContext>) => void}\n query={props.query}\n hasSecretsResolver={hasSecretsResolver}\n hasConstantsResolver={hasConstantsResolver}\n inputFieldsCount={inputFields.length}\n secretsPending={secretSource.pending}\n constantsPending={constantSource.pending}\n overflow={liveOverflow}\n />\n )}\n </SuggestionMenu>\n\n {supportsOutput && (\n <SuggestionMenu\n editor={editor}\n char=\"@\"\n pluginKey=\"liquidEditorDirectivePicker\"\n items={directiveItems}\n >\n {({ items, selectedIndex, onSelect }) => (\n <div className=\"pz:flex pz:flex-col pz:gap-0.5 pz:p-1 pz:min-w-56\" role=\"listbox\">\n <div className=\"pz:px-2 pz:pt-1 pz:pb-0.5 pz:text-[10px] pz:font-medium pz:uppercase pz:tracking-wide pz:text-muted-foreground\">\n Add output declaration\n </div>\n {items.map((item, index) => (\n <button\n key={item.title}\n type=\"button\"\n role=\"option\"\n aria-selected={selectedIndex === index}\n onClick={() => onSelect(item)}\n className={cn(\n \"pz:flex pz:w-full pz:items-center pz:rounded-sm pz:px-2 pz:py-1.5 pz:text-sm pz:text-left pz:cursor-pointer\",\n selectedIndex === index\n ? \"pz:bg-accent pz:text-accent-foreground\"\n : \"pz:hover:bg-accent pz:hover:text-accent-foreground\",\n )}\n >\n {item.title}\n </button>\n ))}\n </div>\n )}\n </SuggestionMenu>\n )}\n\n {chipTarget && editor && (\n <ChipEditPopover editor={editor} target={chipTarget} onClose={() => setChipTarget(null)} />\n )}\n </EditorContext.Provider>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"LiquidEditor.mjs","names":[],"sources":["../../../../src/components/internal/LiquidEditor/LiquidEditor.tsx"],"sourcesContent":["import {\n type PipesFieldDefinitionWithName,\n RECORD_FIELD_TYPES,\n type RecordFieldType,\n} from \"@pipe0/base\";\nimport type { EditorProps } from \"@tiptap/pm/view\";\nimport { EditorContent, EditorContext, useEditor } from \"@tiptap/react\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useAsyncRemoteSource } from \"../../../hooks/use-async-remote-source.js\";\nimport { cn } from \"../../../lib/utils.js\";\nimport type { ConstantSuggestion, SecretSuggestion } from \"../../../types/field-props.js\";\nimport { FieldLegend, type LegendEntry } from \"../field-legend.js\";\nimport { SuggestionMenu } from \"../suggestion-menu/suggestion-menu.js\";\nimport type { SuggestionItem } from \"../suggestion-menu/suggestion-menu-types.js\";\nimport { type ChipHit, TagChipDecoration } from \"../tag-chip-decoration.js\";\nimport { ChipEditPopover, type ChipEditTarget } from \"./ChipEditPopover.js\";\nimport {\n liquidBlockSeparator,\n PLAIN_TEXT_STARTER_KIT,\n textToLiquidDoc,\n} from \"./liquid-document.js\";\nimport {\n buildReferenceItems,\n parseQuery,\n type ReferenceContext,\n SECTION_CAP,\n UnifiedReferencePicker,\n} from \"./UnifiedReferencePicker.js\";\n\nexport type DirectiveKind = \"output\";\n\nexport interface LiquidEditorProps {\n value: string;\n onChange: (v: string) => void;\n inputFields: PipesFieldDefinitionWithName[];\n /**\n * Per-keystroke async searcher for secrets. Called from the `/` reference\n * picker as the user types. Calls are debounced and race-guarded — only\n * the response for the latest query is rendered.\n */\n searchSecrets?: (query: string) => Promise<SecretSuggestion[]>;\n /**\n * Per-keystroke async searcher for constants. Mirrors `searchSecrets`.\n */\n searchConstants?: (query: string) => Promise<ConstantSuggestion[]>;\n multiline?: boolean;\n /**\n * When true, the editor renders within `min-h`/`max-h` bounds. When\n * false, callers control the height via `className`. Default: true.\n */\n autoGrow?: boolean;\n directives?: DirectiveKind[];\n expectedFieldType?: RecordFieldType;\n placeholder?: string;\n className?: string;\n editorProps?: EditorProps;\n /**\n * Suppress the per-editor legend. Used by `loose_object_input` which\n * renders a single legend below the entire list rather than per-cell.\n */\n hideLegend?: boolean;\n}\n\nconst OUTPUT_TEMPLATE = (fieldType: RecordFieldType): string => {\n if (fieldType === \"json\") {\n return `{% output FIELD_NAME, type: \"json\", schema: \"SCHEMA_NAME\", description: \"\" %}`;\n }\n return `{% output FIELD_NAME, type: \"${fieldType}\", description: \"\" %}`;\n};\n\n/**\n * Single editor surface for every form field that accepts Liquid tags.\n * Two triggers, two cognitive categories:\n *\n * - `/` opens a unified reference picker (Fields, Secrets, Constants).\n * Prefix syntax `/f/`, `/s/`, `/c/` filters to one source. Picking\n * emits the corresponding `{{ … }}` Liquid Output.\n *\n * - `@` opens a directives picker (only when `directives.length > 0`).\n * Today the only directive is `output`, which inserts a templated\n * `{% output FIELD_NAME, type: \"...\", description: \"\" %}` block.\n *\n * Chips render via the `TagChipDecoration` extension. Click or\n * Enter/Space on a chip mounts the contextual `ChipEditPopover`.\n *\n * The persisted value is plain Liquid text. `liquid-document.ts` owns the\n * text ⇄ document mapping and keeps it a bijection — the document model is\n * deliberately no richer than the flat string it serializes back to, so a\n * save can never drop characters. No engine, analyzer, or metadata changes.\n */\nexport function LiquidEditor({\n value,\n onChange,\n inputFields,\n searchSecrets,\n searchConstants,\n multiline = false,\n autoGrow = true,\n directives = [],\n expectedFieldType,\n placeholder,\n className,\n editorProps,\n hideLegend = false,\n}: LiquidEditorProps) {\n const blockSeparator = liquidBlockSeparator(multiline);\n const supportsOutput = directives.includes(\"output\");\n const hasSecretsResolver = !!searchSecrets;\n const hasConstantsResolver = !!searchConstants;\n\n const secretSource = useAsyncRemoteSource<SecretSuggestion>({ search: searchSecrets });\n const constantSource = useAsyncRemoteSource<ConstantSuggestion>({ search: searchConstants });\n\n // Warm caches once per resolver identity so the empty-query open is hot.\n useEffect(() => {\n secretSource.prefetch();\n }, [secretSource.prefetch]);\n\n useEffect(() => {\n constantSource.prefetch();\n }, [constantSource.prefetch]);\n\n const [chipTarget, setChipTarget] = useState<ChipEditTarget | null>(null);\n\n const tagChipExtension = useMemo(\n () =>\n TagChipDecoration.configure({\n onChipClick: (hit: ChipHit, el: HTMLElement) => {\n setChipTarget({\n kind: hit.kind,\n raw: hit.raw,\n from: hit.from,\n to: hit.to,\n rect: el.getBoundingClientRect(),\n });\n },\n }),\n [],\n );\n\n const editor = useEditor({\n extensions: [\n StarterKit.configure({\n ...PLAIN_TEXT_STARTER_KIT,\n hardBreak: multiline ? undefined : false,\n }),\n tagChipExtension,\n ],\n parseOptions: { preserveWhitespace: \"full\" },\n editorProps: {\n ...editorProps,\n attributes: {\n class: cn(\n \"pz:px-2.5 pz:py-1 pz:text-sm pz:outline-none pz:break-words\",\n multiline\n ? autoGrow\n ? \"pz:min-h-24 pz:max-h-96 pz:overflow-auto pz:whitespace-pre-wrap\"\n : \"pz:overflow-auto pz:whitespace-pre-wrap\"\n : \"pz:min-h-8 pz:whitespace-pre-wrap\",\n className,\n ),\n ...(placeholder ? { \"data-placeholder\": placeholder } : {}),\n ...editorProps?.attributes,\n },\n handleKeyDown(view, event) {\n if (editorProps?.handleKeyDown?.(view, event)) return true;\n // Single-line: Enter must not split the document. Multi-line: let\n // StarterKit split the paragraph — a boundary serializes to exactly\n // one newline and reloads as a hardBreak, so the value round-trips.\n if (!multiline && event.key === \"Enter\") return true;\n return false;\n },\n },\n content: textToLiquidDoc(value || \"\", multiline),\n onUpdate({ editor }) {\n onChange(editor.getText({ blockSeparator }));\n },\n });\n\n // Sync the editor's document when `value` changes from outside (e.g.\n // form reset, programmatic setValue). `useEditor` only reads `content`\n // at mount; without this hook the visible UI drifts from form state.\n // The early-return short-circuits the loop that the editor's own\n // `onUpdate` would otherwise create — and because the mapping in\n // `liquid-document.ts` is a bijection it genuinely fires for unchanged\n // values, instead of re-setting content on every render.\n useEffect(() => {\n if (!editor) return;\n const current = editor.getText({ blockSeparator });\n if (current === (value ?? \"\")) return;\n editor.commands.setContent(textToLiquidDoc(value ?? \"\", multiline), {\n emitUpdate: false,\n });\n }, [editor, value, multiline, blockSeparator]);\n\n const insertField = useCallback((name: string) => `{{ ${name} }}`, []);\n\n // Tracks the current query in the open reference picker. Drives the\n // `liveItems` recomputation below — when the async sources resolve, we\n // re-render the picker against this query without waiting for Tiptap to\n // re-call items().\n const [referenceQuery, setReferenceQuery] = useState(\"\");\n\n // In sectioned (empty-query) view, cap each kind at SECTION_CAP visible\n // rows. Users see a compact summary and type to filter; the act of\n // filtering switches to flat mode where all matches are shown.\n // Caps are applied at this layer (not inside the picker) so the array\n // passed to SuggestionMenu matches what's rendered — keyboard nav\n // operates on the visible set and never lands on a hidden row.\n const buildVisibleItems = useCallback(\n (\n query: string,\n ): {\n items: SuggestionItem<ReferenceContext>[];\n overflow: Record<ReferenceContext[\"kind\"], number>;\n } => {\n const all = buildReferenceItems(\n query,\n {\n inputFields,\n secretSuggestions: secretSource.items,\n constantSuggestions: constantSource.items,\n },\n expectedFieldType,\n insertField,\n );\n const { prefix, residual } = parseQuery(query);\n const isFlat = !!residual.trim() || prefix !== null;\n if (isFlat) {\n return { items: all, overflow: { field: 0, secret: 0, constant: 0 } };\n }\n const fields = all.filter((i) => i.context?.kind === \"field\");\n const secrets = all.filter((i) => i.context?.kind === \"secret\");\n const constants = all.filter((i) => i.context?.kind === \"constant\");\n return {\n items: [\n ...fields.slice(0, SECTION_CAP),\n ...secrets.slice(0, SECTION_CAP),\n ...constants.slice(0, SECTION_CAP),\n ],\n overflow: {\n field: Math.max(0, fields.length - SECTION_CAP),\n secret: Math.max(0, secrets.length - SECTION_CAP),\n constant: Math.max(0, constants.length - SECTION_CAP),\n },\n };\n },\n [inputFields, expectedFieldType, insertField, secretSource.items, constantSource.items],\n );\n\n const referenceItems = useCallback(\n ({ query }: { query: string }): SuggestionItem<ReferenceContext>[] => {\n setReferenceQuery(query);\n // Fire-and-forget: results land via React state in `secretSource.items`\n // / `constantSource.items` and trigger a re-render. The popup reads\n // them through `liveItems` (see below).\n secretSource.ensure(query);\n constantSource.ensure(query);\n return buildVisibleItems(query).items;\n },\n [secretSource.ensure, constantSource.ensure, buildVisibleItems],\n );\n\n // Live items derived from the current query + cached source items. This is\n // recomputed whenever an async source resolves (which updates\n // `secretSource.items` / `constantSource.items` via setState). Passed to\n // SuggestionMenu via `liveItems` so the open popup re-renders without\n // needing the user to type — Tiptap's items() callback alone cannot\n // deliver async results that arrive between keystrokes.\n const { items: liveReferenceItems, overflow: liveOverflow } = useMemo(\n () => buildVisibleItems(referenceQuery),\n [referenceQuery, buildVisibleItems],\n );\n\n const directiveItems = useCallback(\n () =>\n RECORD_FIELD_TYPES.map<SuggestionItem>((fieldType) => ({\n title: `${fieldType}`,\n keywords: [fieldType, \"output\"],\n onSelect: ({ editor: ed, range }) => {\n ed.chain().focus().insertContentAt(range, OUTPUT_TEMPLATE(fieldType)).run();\n },\n })),\n [],\n );\n\n const legendEntries: LegendEntry[] = [];\n legendEntries.push({ key: \"/\", label: \"to insert a reference\" });\n if (supportsOutput) legendEntries.push({ key: \"@\", label: \"to add an output field\" });\n\n return (\n <EditorContext.Provider value={{ editor }}>\n <EditorContent editor={editor} />\n\n {!hideLegend && <FieldLegend entries={legendEntries} />}\n\n <SuggestionMenu\n editor={editor}\n char=\"/\"\n pluginKey=\"liquidEditorReferencePicker\"\n items={referenceItems}\n liveItems={liveReferenceItems}\n >\n {(props) => (\n <UnifiedReferencePicker\n items={props.items as SuggestionItem<ReferenceContext>[]}\n selectedIndex={props.selectedIndex}\n onSelect={props.onSelect as (item: SuggestionItem<ReferenceContext>) => void}\n query={props.query}\n hasSecretsResolver={hasSecretsResolver}\n hasConstantsResolver={hasConstantsResolver}\n inputFieldsCount={inputFields.length}\n secretsPending={secretSource.pending}\n constantsPending={constantSource.pending}\n overflow={liveOverflow}\n />\n )}\n </SuggestionMenu>\n\n {supportsOutput && (\n <SuggestionMenu\n editor={editor}\n char=\"@\"\n pluginKey=\"liquidEditorDirectivePicker\"\n items={directiveItems}\n >\n {({ items, selectedIndex, onSelect }) => (\n <div className=\"pz:flex pz:flex-col pz:gap-0.5 pz:p-1 pz:min-w-56\" role=\"listbox\">\n <div className=\"pz:px-2 pz:pt-1 pz:pb-0.5 pz:text-[10px] pz:font-medium pz:uppercase pz:tracking-wide pz:text-muted-foreground\">\n Add output declaration\n </div>\n {items.map((item, index) => (\n <button\n key={item.title}\n type=\"button\"\n role=\"option\"\n aria-selected={selectedIndex === index}\n onClick={() => onSelect(item)}\n className={cn(\n \"pz:flex pz:w-full pz:items-center pz:rounded-sm pz:px-2 pz:py-1.5 pz:text-sm pz:text-left pz:cursor-pointer\",\n selectedIndex === index\n ? \"pz:bg-accent pz:text-accent-foreground\"\n : \"pz:hover:bg-accent pz:hover:text-accent-foreground\",\n )}\n >\n {item.title}\n </button>\n ))}\n </div>\n )}\n </SuggestionMenu>\n )}\n\n {chipTarget && editor && (\n <ChipEditPopover editor={editor} target={chipTarget} onClose={() => setChipTarget(null)} />\n )}\n </EditorContext.Provider>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,74 @@
1
+ //#region src/components/internal/LiquidEditor/liquid-document.ts
2
+ /**
3
+ * The plain-text ⇄ ProseMirror mapping for `LiquidEditor`.
4
+ *
5
+ * The persisted value is a flat Liquid string, so the document model must be
6
+ * no richer than that string can express — otherwise every save silently
7
+ * drops whatever does not survive serialization. Two rules keep the mapping a
8
+ * bijection:
9
+ *
10
+ * 1. The schema is `doc / paragraph / text / hardBreak` and nothing else
11
+ * (see `PLAIN_TEXT_STARTER_KIT`). No marks, no lists, no headings — and
12
+ * therefore no markdown input rule that eats `-`, `#`, `*` or backticks
13
+ * as the user types them.
14
+ * 2. A newline is always a `hardBreak`, never a paragraph boundary. Tiptap's
15
+ * `getText` emits exactly one `blockSeparator` per block boundary, so
16
+ * encoding `\n\n` as two paragraphs loses one newline on every read-back.
17
+ * `HardBreak.renderText()` returns `"\n"`, so breaks round-trip exactly.
18
+ *
19
+ * `blockSeparator` still matters for documents we did not build ourselves —
20
+ * pressing Enter splits the paragraph, and a rich paste can land several. One
21
+ * separator per boundary keeps those at one newline each.
22
+ */
23
+ /**
24
+ * StarterKit options that reduce the schema to plain text. Every extension
25
+ * disabled here can only ever *lose* characters, because none of them can be
26
+ * represented in the persisted string.
27
+ */
28
+ const PLAIN_TEXT_STARTER_KIT = {
29
+ bold: false,
30
+ italic: false,
31
+ strike: false,
32
+ code: false,
33
+ underline: false,
34
+ link: false,
35
+ heading: false,
36
+ blockquote: false,
37
+ bulletList: false,
38
+ orderedList: false,
39
+ listItem: false,
40
+ listKeymap: false,
41
+ codeBlock: false,
42
+ horizontalRule: false
43
+ };
44
+ /** Separator `editor.getText` inserts at a block boundary. */
45
+ function liquidBlockSeparator(multiline) {
46
+ return multiline ? "\n" : " ";
47
+ }
48
+ /**
49
+ * Builds the editor document for a stored template string. Single-line fields
50
+ * collapse newline runs to a single space; multi-line fields keep every
51
+ * newline as a `hardBreak`.
52
+ */
53
+ function textToLiquidDoc(text, multiline) {
54
+ const normalized = multiline ? text : text.replace(/\s*\n+\s*/g, " ");
55
+ const content = [];
56
+ normalized.split("\n").forEach((line, index) => {
57
+ if (index > 0) content.push({ type: "hardBreak" });
58
+ if (line) content.push({
59
+ type: "text",
60
+ text: line
61
+ });
62
+ });
63
+ return {
64
+ type: "doc",
65
+ content: [content.length > 0 ? {
66
+ type: "paragraph",
67
+ content
68
+ } : { type: "paragraph" }]
69
+ };
70
+ }
71
+
72
+ //#endregion
73
+ export { PLAIN_TEXT_STARTER_KIT, liquidBlockSeparator, textToLiquidDoc };
74
+ //# sourceMappingURL=liquid-document.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"liquid-document.mjs","names":[],"sources":["../../../../src/components/internal/LiquidEditor/liquid-document.ts"],"sourcesContent":["import type { JSONContent } from \"@tiptap/react\";\n\n/**\n * The plain-text ⇄ ProseMirror mapping for `LiquidEditor`.\n *\n * The persisted value is a flat Liquid string, so the document model must be\n * no richer than that string can express — otherwise every save silently\n * drops whatever does not survive serialization. Two rules keep the mapping a\n * bijection:\n *\n * 1. The schema is `doc / paragraph / text / hardBreak` and nothing else\n * (see `PLAIN_TEXT_STARTER_KIT`). No marks, no lists, no headings — and\n * therefore no markdown input rule that eats `-`, `#`, `*` or backticks\n * as the user types them.\n * 2. A newline is always a `hardBreak`, never a paragraph boundary. Tiptap's\n * `getText` emits exactly one `blockSeparator` per block boundary, so\n * encoding `\\n\\n` as two paragraphs loses one newline on every read-back.\n * `HardBreak.renderText()` returns `\"\\n\"`, so breaks round-trip exactly.\n *\n * `blockSeparator` still matters for documents we did not build ourselves —\n * pressing Enter splits the paragraph, and a rich paste can land several. One\n * separator per boundary keeps those at one newline each.\n */\n\n/**\n * StarterKit options that reduce the schema to plain text. Every extension\n * disabled here can only ever *lose* characters, because none of them can be\n * represented in the persisted string.\n */\nexport const PLAIN_TEXT_STARTER_KIT = {\n bold: false,\n italic: false,\n strike: false,\n code: false,\n underline: false,\n link: false,\n heading: false,\n blockquote: false,\n bulletList: false,\n orderedList: false,\n listItem: false,\n listKeymap: false,\n codeBlock: false,\n horizontalRule: false,\n} as const;\n\n/** Separator `editor.getText` inserts at a block boundary. */\nexport function liquidBlockSeparator(multiline: boolean): string {\n // Single-line editors collapse newlines to a space on the way in, so a\n // pasted multi-paragraph value must collapse the same way on the way out —\n // with `\"\"` the last word of one line fuses into the first of the next.\n return multiline ? \"\\n\" : \" \";\n}\n\n/**\n * Builds the editor document for a stored template string. Single-line fields\n * collapse newline runs to a single space; multi-line fields keep every\n * newline as a `hardBreak`.\n */\nexport function textToLiquidDoc(text: string, multiline: boolean): JSONContent {\n const normalized = multiline ? text : text.replace(/\\s*\\n+\\s*/g, \" \");\n const content: JSONContent[] = [];\n\n normalized.split(\"\\n\").forEach((line, index) => {\n if (index > 0) content.push({ type: \"hardBreak\" });\n // ProseMirror rejects empty text nodes, so blank lines contribute only\n // their surrounding breaks.\n if (line) content.push({ type: \"text\", text: line });\n });\n\n return {\n type: \"doc\",\n content: [content.length > 0 ? { type: \"paragraph\", content } : { type: \"paragraph\" }],\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,MAAa,yBAAyB;CACpC,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,MAAM;CACN,WAAW;CACX,MAAM;CACN,SAAS;CACT,YAAY;CACZ,YAAY;CACZ,aAAa;CACb,UAAU;CACV,YAAY;CACZ,WAAW;CACX,gBAAgB;CACjB;;AAGD,SAAgB,qBAAqB,WAA4B;AAI/D,QAAO,YAAY,OAAO;;;;;;;AAQ5B,SAAgB,gBAAgB,MAAc,WAAiC;CAC7E,MAAM,aAAa,YAAY,OAAO,KAAK,QAAQ,cAAc,IAAI;CACrE,MAAM,UAAyB,EAAE;AAEjC,YAAW,MAAM,KAAK,CAAC,SAAS,MAAM,UAAU;AAC9C,MAAI,QAAQ,EAAG,SAAQ,KAAK,EAAE,MAAM,aAAa,CAAC;AAGlD,MAAI,KAAM,SAAQ,KAAK;GAAE,MAAM;GAAQ,MAAM;GAAM,CAAC;GACpD;AAEF,QAAO;EACL,MAAM;EACN,SAAS,CAAC,QAAQ,SAAS,IAAI;GAAE,MAAM;GAAa;GAAS,GAAG,EAAE,MAAM,aAAa,CAAC;EACvF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipe0/react",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "React component library for building forms and catalogs powered by pipe0 pipes and searches.",
5
5
  "license": "MIT",
6
6
  "author": "pipe0",
@@ -72,6 +72,7 @@
72
72
  "@radix-ui/react-slot": "^1.2.2",
73
73
  "@tanstack/match-sorter-utils": "^8.19.4",
74
74
  "@tanstack/react-table": "^8.21.3",
75
+ "@tiptap/core": "^3.7.2",
75
76
  "@tiptap/pm": "^3.7.2",
76
77
  "@tiptap/react": "^3.7.2",
77
78
  "@tiptap/starter-kit": "^3.7.2",
@@ -84,7 +85,7 @@
84
85
  "react-hook-form": "^7.62.0",
85
86
  "swr": "^2.4.1",
86
87
  "tailwind-merge": "^3.3.1",
87
- "@pipe0/base": "1.0.22"
88
+ "@pipe0/base": "1.0.24"
88
89
  },
89
90
  "devDependencies": {
90
91
  "@base-ui/react": "^1.6.0",
@@ -105,6 +106,7 @@
105
106
  "tw-animate-css": "^1.4.0",
106
107
  "typescript": "^6.0.3",
107
108
  "vite": "^8.0.10",
109
+ "vitest": "4.1.9",
108
110
  "zod": "4.1.12"
109
111
  },
110
112
  "scripts": {
@@ -113,6 +115,7 @@
113
115
  "dev": "pnpm run \"/^dev:/\"",
114
116
  "build": "tsdown",
115
117
  "typecheck": "tsc --noEmit",
118
+ "test": "vitest run",
116
119
  "lint": "biome check .",
117
120
  "lint:fix": "biome check --write .",
118
121
  "storybook": "storybook dev -p 6006",