@scrider/formatter 1.2.0 → 1.3.0

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/README.md CHANGED
@@ -8,7 +8,7 @@ Schema, conversion and block handlers for rich-text content. HTML, Markdown, san
8
8
 
9
9
  ## Key Features
10
10
 
11
- - **Schema** — extensible format registry (31 built-in formats: inline, block, embed)
11
+ - **Schema** — extensible format registry (32 built-in formats: inline, block, embed — including `softBreak` for Shift+Enter line breaks)
12
12
  - **HTML conversion** — `deltaToHtml()` / `htmlToDelta()` with DOM adapters (browser + Node.js)
13
13
  - **Markdown conversion** — `deltaToMarkdown()` / `markdownToDelta()` (GFM, math, footnotes)
14
14
  - **Block handlers** — tables, footnotes, alerts, columns, inline-box
@@ -65,7 +65,7 @@ const delta = htmlToDelta(html, { registry });
65
65
  ```typescript
66
66
  import { Registry, createDefaultRegistry, BlockHandlerRegistry } from '@scrider/formatter';
67
67
 
68
- const registry = createDefaultRegistry(); // 31 built-in formats
68
+ const registry = createDefaultRegistry(); // 32 built-in formats
69
69
  ```
70
70
 
71
71
  ### HTML Conversion
@@ -86,6 +86,35 @@ deltaToMarkdown(delta, options?) // Delta → Markdown string
86
86
  await markdownToDelta(markdown, options?) // Markdown string → Delta (async)
87
87
  ```
88
88
 
89
+ ### Soft Line Break (`softBreak` embed)
90
+
91
+ A `Shift+Enter` style line break that does **not** split the containing block. Stored in Delta as `{ insert: { softBreak: true } }` and round-tripped consistently across all three layers:
92
+
93
+ | Direction | Encoding |
94
+ |-----------|----------|
95
+ | HTML | `<br data-scrider-embed>` (the marker disambiguates it from the `<br>` placeholder inside an empty paragraph) |
96
+ | Markdown | `" \n"` by default — GFM hard break; switch to inline `<br>` via `deltaToMarkdown(delta, { softBreakStyle: 'html' })` |
97
+
98
+ `htmlToDelta` also recognises bare `<br>` between content (e.g. `<p>foo<br>bar</p>`) as a soft break, while keeping the leading / placeholder shapes (`<p><br></p>`, `<p><br>foo</p>`) as regular newlines for backward compatibility.
99
+
100
+ ```typescript
101
+ import { Delta, deltaToHtml, deltaToMarkdown } from '@scrider/formatter';
102
+
103
+ const doc = new Delta()
104
+ .insert('hello')
105
+ .insert({ softBreak: true })
106
+ .insert('world\n');
107
+
108
+ deltaToHtml(doc);
109
+ // → '<p>hello<br data-scrider-embed>world</p>'
110
+
111
+ deltaToMarkdown(doc);
112
+ // → 'hello \nworld'
113
+
114
+ deltaToMarkdown(doc, { softBreakStyle: 'html' });
115
+ // → 'hello<br>world'
116
+ ```
117
+
89
118
  ### Sanitization
90
119
 
91
120
  ```typescript
package/dist/index.cjs CHANGED
@@ -96,6 +96,7 @@ __export(index_exports, {
96
96
  sizeFormat: () => sizeFormat,
97
97
  slugify: () => slugify,
98
98
  slugifyWithDedup: () => slugifyWithDedup,
99
+ softBreakFormat: () => softBreakFormat,
99
100
  strikeFormat: () => strikeFormat,
100
101
  subscriptFormat: () => subscriptFormat,
101
102
  superscriptFormat: () => superscriptFormat,
@@ -2020,7 +2021,13 @@ var EMBED_RENDERERS = {
2020
2021
  const id = typeof value === "string" ? value : String(value);
2021
2022
  return `<sup class="footnote-ref"><a href="#fn-${escapeHtml(id)}" id="fnref-${escapeHtml(id)}">[${escapeHtml(id)}]</a></sup>`;
2022
2023
  },
2023
- divider: () => "<hr>"
2024
+ divider: () => "<hr>",
2025
+ // Soft line break (Shift+Enter equivalent). Emitted with an explicit
2026
+ // `data-scrider-embed` marker so that html-to-delta can distinguish this
2027
+ // embed from the placeholder `<br>` that appears inside an empty
2028
+ // paragraph (`<p><br></p>`) without relying solely on positional
2029
+ // heuristics. See `soft-break.ts` for the format definition.
2030
+ softBreak: () => "<br data-scrider-embed>"
2024
2031
  };
2025
2032
  var TAG_TO_INLINE_FORMAT = {
2026
2033
  strong: { format: "bold", value: true },
@@ -2258,6 +2265,33 @@ var imageFormat = {
2258
2265
  }
2259
2266
  };
2260
2267
 
2268
+ // src/schema/formats/embed/soft-break.ts
2269
+ var softBreakFormat = {
2270
+ name: "softBreak",
2271
+ scope: "embed",
2272
+ normalize(value) {
2273
+ return !!value;
2274
+ },
2275
+ validate(value) {
2276
+ return value === true;
2277
+ },
2278
+ render() {
2279
+ return "<br data-scrider-embed>";
2280
+ },
2281
+ match(element) {
2282
+ if (element.tagName.toLowerCase() !== "br") return null;
2283
+ if (!element.hasAttribute("data-scrider-embed")) return null;
2284
+ return { value: true };
2285
+ }
2286
+ // NB: Markdown rendering is intentionally NOT implemented on the format
2287
+ // itself. The choice between `" \n"` (GFM spaces) and inline `<br>`
2288
+ // depends on the caller-provided `softBreakStyle` option on
2289
+ // `deltaToMarkdown`, so the converter handles it as a built-in special
2290
+ // case instead of going through `Format.toMarkdown`. The Markdown side
2291
+ // of the round-trip is symmetric: `markdownToDelta` recognises both
2292
+ // `break` AST nodes and inline `<br>` HTML and emits this embed.
2293
+ };
2294
+
2261
2295
  // src/schema/formats/embed/video.ts
2262
2296
  var videoFormat = {
2263
2297
  name: "video",
@@ -2360,6 +2394,7 @@ var defaultEmbedFormats = [
2360
2394
  videoFormat,
2361
2395
  formulaFormat,
2362
2396
  dividerFormat,
2397
+ softBreakFormat,
2363
2398
  blockFormat,
2364
2399
  footnoteRefFormat
2365
2400
  ];
@@ -3190,7 +3225,12 @@ function htmlToDelta(html, options = {}) {
3190
3225
  return;
3191
3226
  }
3192
3227
  if (tagName === "br") {
3193
- context.pushNewline();
3228
+ const hasMarker = node.hasAttribute("data-scrider-embed");
3229
+ if (hasMarker || hasMeaningfulPrevSibling(node)) {
3230
+ context.pushEmbed({ softBreak: true });
3231
+ } else {
3232
+ context.pushNewline();
3233
+ }
3194
3234
  return;
3195
3235
  }
3196
3236
  processChildren(node);
@@ -3624,6 +3664,23 @@ function normalizeText(text, pendingText, atLineStart) {
3624
3664
  }
3625
3665
  return text;
3626
3666
  }
3667
+ function hasMeaningfulPrevSibling(brNode) {
3668
+ const parent = brNode.parentNode;
3669
+ if (!parent) return false;
3670
+ const children = parent.childNodes;
3671
+ for (let i = 0; i < children.length; i++) {
3672
+ const child = children[i];
3673
+ if (!child) continue;
3674
+ if (child === brNode) return false;
3675
+ if (child.nodeType === NODE_TYPE.TEXT_NODE) {
3676
+ const text = child.textContent ?? "";
3677
+ if (text.trim().length > 0) return true;
3678
+ } else if (isElement(child)) {
3679
+ return true;
3680
+ }
3681
+ }
3682
+ return false;
3683
+ }
3627
3684
  function findTagHandler(handlers, element, tagName) {
3628
3685
  const className = element.getAttribute("class");
3629
3686
  if (className) {
@@ -3760,6 +3817,7 @@ function deltaToMarkdown(delta, options = {}) {
3760
3817
  blockHandlers,
3761
3818
  prettyHtml = false,
3762
3819
  registry,
3820
+ softBreakStyle = "spaces",
3763
3821
  trimTrailingNewlines = false
3764
3822
  } = options;
3765
3823
  const useLatexDelimiters = mathSyntax === "latex";
@@ -3776,7 +3834,9 @@ function deltaToMarkdown(delta, options = {}) {
3776
3834
  const isBlockquote = !!attrs.blockquote;
3777
3835
  if (typeof attrs["table-row"] === "number" && typeof attrs["table-col"] === "number") {
3778
3836
  const tableLines = collectTableLines2(lines, i);
3779
- result.push(renderMarkdownTable(tableLines, embedRenderers, useLatexDelimiters, registry));
3837
+ result.push(
3838
+ renderMarkdownTable(tableLines, embedRenderers, useLatexDelimiters, registry, softBreakStyle)
3839
+ );
3780
3840
  result.push("");
3781
3841
  i += tableLines.length - 1;
3782
3842
  lastListType = null;
@@ -3792,7 +3852,16 @@ function deltaToMarkdown(delta, options = {}) {
3792
3852
  const codeLines = collectCodeBlock(lines, i);
3793
3853
  const language = getCodeBlockLanguage2(attrs);
3794
3854
  const code = codeLines.map(
3795
- (l) => renderLineContent2(l.ops, embedRenderers, true, false, blockHandlers, false, registry)
3855
+ (l) => renderLineContent2(
3856
+ l.ops,
3857
+ embedRenderers,
3858
+ true,
3859
+ false,
3860
+ blockHandlers,
3861
+ false,
3862
+ registry,
3863
+ softBreakStyle
3864
+ )
3796
3865
  ).join("\n");
3797
3866
  if (language === "math") {
3798
3867
  if (mathBlock === false) {
@@ -3842,7 +3911,8 @@ ${code}
3842
3911
  useLatexDelimiters,
3843
3912
  blockHandlers,
3844
3913
  prettyHtml,
3845
- registry
3914
+ registry,
3915
+ softBreakStyle
3846
3916
  );
3847
3917
  if (!content && !hasBlockFormat(attrs)) {
3848
3918
  result.push(preserveEmptyLines ? "<br>" : "");
@@ -3944,7 +4014,7 @@ function collectTableLines2(lines, startIndex) {
3944
4014
  }
3945
4015
  return result;
3946
4016
  }
3947
- function renderMarkdownTable(tableLines, embedRenderers, useLatexDelimiters = false, registry) {
4017
+ function renderMarkdownTable(tableLines, embedRenderers, useLatexDelimiters = false, registry, softBreakStyle = "spaces") {
3948
4018
  const rows = /* @__PURE__ */ new Map();
3949
4019
  for (const line of tableLines) {
3950
4020
  const attrs = line.attributes;
@@ -3981,7 +4051,9 @@ function renderMarkdownTable(tableLines, embedRenderers, useLatexDelimiters = fa
3981
4051
  const mdLines = [];
3982
4052
  if (headerRows.length > 0) {
3983
4053
  for (const [, row] of headerRows) {
3984
- mdLines.push(renderMdRow(row.cells, maxCol, embedRenderers, useLatexDelimiters, registry));
4054
+ mdLines.push(
4055
+ renderMdRow(row.cells, maxCol, embedRenderers, useLatexDelimiters, registry, softBreakStyle)
4056
+ );
3985
4057
  }
3986
4058
  mdLines.push(renderMdSeparator(maxCol, colAligns));
3987
4059
  } else {
@@ -3989,15 +4061,19 @@ function renderMarkdownTable(tableLines, embedRenderers, useLatexDelimiters = fa
3989
4061
  for (let col = 0; col <= maxCol; col++) {
3990
4062
  emptyRow.set(col, { ops: [] });
3991
4063
  }
3992
- mdLines.push(renderMdRow(emptyRow, maxCol, embedRenderers, useLatexDelimiters, registry));
4064
+ mdLines.push(
4065
+ renderMdRow(emptyRow, maxCol, embedRenderers, useLatexDelimiters, registry, softBreakStyle)
4066
+ );
3993
4067
  mdLines.push(renderMdSeparator(maxCol, colAligns));
3994
4068
  }
3995
4069
  for (const [, row] of bodyRows) {
3996
- mdLines.push(renderMdRow(row.cells, maxCol, embedRenderers, useLatexDelimiters, registry));
4070
+ mdLines.push(
4071
+ renderMdRow(row.cells, maxCol, embedRenderers, useLatexDelimiters, registry, softBreakStyle)
4072
+ );
3997
4073
  }
3998
4074
  return mdLines.join("\n");
3999
4075
  }
4000
- function renderMdRow(cells, maxCol, embedRenderers, useLatexDelimiters = false, registry) {
4076
+ function renderMdRow(cells, maxCol, embedRenderers, useLatexDelimiters = false, registry, softBreakStyle = "spaces") {
4001
4077
  const parts = [];
4002
4078
  for (let col = 0; col <= maxCol; col++) {
4003
4079
  const cell = cells.get(col);
@@ -4008,7 +4084,10 @@ function renderMdRow(cells, maxCol, embedRenderers, useLatexDelimiters = false,
4008
4084
  useLatexDelimiters,
4009
4085
  void 0,
4010
4086
  false,
4011
- registry
4087
+ registry,
4088
+ softBreakStyle,
4089
+ true
4090
+ // inTableCell — softBreak must use <br>, never " \n"
4012
4091
  ) : "";
4013
4092
  parts.push(content.replace(/\|/g, "\\|"));
4014
4093
  }
@@ -4037,7 +4116,7 @@ function getCodeBlockLanguage2(attributes) {
4037
4116
  }
4038
4117
  return void 0;
4039
4118
  }
4040
- function renderLineContent2(ops, embedRenderers, inCodeBlock, useLatexDelimiters = false, blockHandlers, prettyHtml = false, registry) {
4119
+ function renderLineContent2(ops, embedRenderers, inCodeBlock, useLatexDelimiters = false, blockHandlers, prettyHtml = false, registry, softBreakStyle = "spaces", inTableCell = false) {
4041
4120
  let result = "";
4042
4121
  for (const op of ops) {
4043
4122
  const attrs = op.attributes;
@@ -4057,7 +4136,9 @@ function renderLineContent2(ops, embedRenderers, inCodeBlock, useLatexDelimiters
4057
4136
  useLatexDelimiters,
4058
4137
  blockHandlers,
4059
4138
  prettyHtml,
4060
- registry
4139
+ registry,
4140
+ softBreakStyle,
4141
+ inTableCell
4061
4142
  );
4062
4143
  }
4063
4144
  }
@@ -4091,13 +4172,17 @@ function renderInlineText2(text, attributes) {
4091
4172
  }
4092
4173
  return result;
4093
4174
  }
4094
- function renderEmbed2(embed, attributes, customRenderers, useLatexDelimiters = false, blockHandlers, prettyHtml = false, registry) {
4175
+ function renderEmbed2(embed, attributes, customRenderers, useLatexDelimiters = false, blockHandlers, prettyHtml = false, registry, softBreakStyle = "spaces", inTableCell = false) {
4095
4176
  const entries = Object.entries(embed);
4096
4177
  if (entries.length === 0) return "";
4097
4178
  const firstEntry = entries[0];
4098
4179
  if (!firstEntry) return "";
4099
4180
  const embedType = firstEntry[0];
4100
4181
  const embedValue = firstEntry[1];
4182
+ if (embedType === "softBreak") {
4183
+ if (inTableCell) return "<br>";
4184
+ return softBreakStyle === "html" ? "<br>" : " \n";
4185
+ }
4101
4186
  if (embedType === "block" && blockHandlers) {
4102
4187
  const blockData = embedValue;
4103
4188
  if (blockData && typeof blockData.type === "string") {
@@ -4586,7 +4671,7 @@ function astToDelta(tree, customHandlers, mathBlock, mermaidBlock, plantumlBlock
4586
4671
  footnoteDefinitions.set(node.identifier ?? "", node);
4587
4672
  break;
4588
4673
  case "break":
4589
- context.pushNewline();
4674
+ context.pushEmbed({ softBreak: true });
4590
4675
  break;
4591
4676
  case "html": {
4592
4677
  const htmlContent = node.value ?? "";
@@ -4892,8 +4977,8 @@ function astToDelta(tree, customHandlers, mathBlock, mermaidBlock, plantumlBlock
4892
4977
  context.pushNewline();
4893
4978
  return;
4894
4979
  }
4895
- if (/^<br\s*\/?>$/i.test(html)) {
4896
- context.pushNewline();
4980
+ if (/^<br\b[^>]*\/?>$/i.test(html)) {
4981
+ context.pushEmbed({ softBreak: true });
4897
4982
  return;
4898
4983
  }
4899
4984
  }
@@ -5044,6 +5129,7 @@ function extractTableRegion(ops, hintOpIdx) {
5044
5129
  sizeFormat,
5045
5130
  slugify,
5046
5131
  slugifyWithDedup,
5132
+ softBreakFormat,
5047
5133
  strikeFormat,
5048
5134
  subscriptFormat,
5049
5135
  superscriptFormat,