svelte-rtf-editor 1.0.3 → 1.1.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.
@@ -5,24 +5,22 @@
5
5
  onchange?: (color: string) => void;
6
6
  }
7
7
 
8
- let { value = '#2C2520', title = 'Color', onchange }: Props = $props();
9
-
10
- let currentColor = $state(value);
8
+ let { value = $bindable('#2C2520'), title = 'Color', onchange }: Props = $props();
11
9
 
12
10
  function handleChange(e: Event) {
13
- currentColor = (e.target as HTMLInputElement).value;
14
- onchange?.(currentColor);
11
+ value = (e.target as HTMLInputElement).value;
12
+ onchange?.(value);
15
13
  }
16
14
  </script>
17
15
 
18
16
  <div class="color-picker-wrap">
19
17
  <button class="color-swatch" {title} type="button">
20
- <div class="color-swatch-inner" style="background: {currentColor};"></div>
18
+ <div class="color-swatch-inner" style="background: {value};"></div>
21
19
  </button>
22
20
  <input
23
21
  type="color"
24
22
  class="color-input"
25
- value={currentColor}
23
+ value={value}
26
24
  onchange={handleChange}
27
25
  {title}
28
26
  />
@@ -3,6 +3,6 @@ interface Props {
3
3
  title?: string;
4
4
  onchange?: (color: string) => void;
5
5
  }
6
- declare const ColorPicker: import("svelte").Component<Props, {}, "">;
6
+ declare const ColorPicker: import("svelte").Component<Props, {}, "value">;
7
7
  type ColorPicker = ReturnType<typeof ColorPicker>;
8
8
  export default ColorPicker;
@@ -187,6 +187,13 @@
187
187
  function exec(cmd: string, value: string | null = null): void {
188
188
  restoreSelection();
189
189
  editorEl?.focus();
190
+ // Color commands require an actual text selection — a collapsed cursor
191
+ // would silently apply the color to nothing (or to subsequently typed
192
+ // text), which causes accidental color spans to appear in the RTF output.
193
+ if (cmd === 'foreColor' || cmd === 'hiliteColor') {
194
+ const sel = window.getSelection();
195
+ if (!sel || sel.isCollapsed) return;
196
+ }
190
197
  document.execCommand(cmd, false, value ?? undefined);
191
198
  updateToolbarState();
192
199
  }
@@ -65,7 +65,6 @@
65
65
  <!-- Colors -->
66
66
  <div class="toolbar-group">
67
67
  <ColorPicker value="#2C2520" title="Text color" onchange={(c) => exec('foreColor', c)} />
68
- <ColorPicker value="#FFFBCC" title="Highlight" onchange={(c) => exec('hiliteColor', c)} />
69
68
  </div>
70
69
 
71
70
  <div class="toolbar-divider"></div>
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Parses RTF control words and groups into an HTML string.
5
5
  * Supports: bold, italic, underline, strikethrough, super/subscript,
6
- * font size, foreground color, color table, font table, paragraphs,
7
- * bullet/numbered lists, Unicode characters, hex escapes, and nested groups.
6
+ * font size, foreground color (\cf), highlight background color (\highlight),
7
+ * color table, font table, paragraphs, bullet/numbered lists,
8
+ * Unicode characters, hex escapes, and nested groups.
9
+ *
10
+ * \cb (character background) is silently ignored on import.
8
11
  */
9
12
  export declare function rtfToHtml(rtfString: string): string;
10
13
  export declare function readRtfFile(file: File): Promise<string>;
@@ -3,9 +3,31 @@
3
3
  *
4
4
  * Parses RTF control words and groups into an HTML string.
5
5
  * Supports: bold, italic, underline, strikethrough, super/subscript,
6
- * font size, foreground color, color table, font table, paragraphs,
7
- * bullet/numbered lists, Unicode characters, hex escapes, and nested groups.
6
+ * font size, foreground color (\cf), highlight background color (\highlight),
7
+ * color table, font table, paragraphs, bullet/numbered lists,
8
+ * Unicode characters, hex escapes, and nested groups.
9
+ *
10
+ * \cb (character background) is silently ignored on import.
8
11
  */
12
+ // ── Highlight palette (RTF \highlight 1-16) ──
13
+ const HIGHLIGHT_COLORS = {
14
+ 1: '#000000', // Black
15
+ 2: '#0000ff', // Blue
16
+ 3: '#00ffff', // Cyan
17
+ 4: '#00ff00', // Green
18
+ 5: '#ff00ff', // Magenta
19
+ 6: '#ff0000', // Red
20
+ 7: '#ffff00', // Yellow
21
+ 8: '#ffffff', // White
22
+ 9: '#000080', // Dark Blue
23
+ 10: '#008080', // Dark Cyan
24
+ 11: '#008000', // Dark Green
25
+ 12: '#800080', // Dark Magenta
26
+ 13: '#800000', // Dark Red
27
+ 14: '#808000', // Dark Yellow
28
+ 15: '#808080', // Dark Gray
29
+ 16: '#c0c0c0', // Light Gray
30
+ };
9
31
  // ── Tokenizer ──
10
32
  function tokenize(rtf) {
11
33
  const tokens = [];
@@ -136,25 +158,40 @@ function buildTree(tokens) {
136
158
  }
137
159
  // ── Color table parser ──
138
160
  function parseColorTable(group) {
139
- const colors = ['']; // index 0 = auto/default (black)
161
+ const colors = ['']; // index 0 = auto/default
140
162
  let r = 0, g = 0, b = 0;
163
+ let hasComponents = false;
164
+ function finalizeEntry() {
165
+ // In standard RTF, a leading ";" represents the auto color (index 0).
166
+ // We already reserve colors[0] for auto, so do not append another entry.
167
+ if (colors.length === 1 && !hasComponents)
168
+ return;
169
+ colors.push(`rgb(${r},${g},${b})`);
170
+ }
141
171
  for (const node of group.children) {
142
172
  if (node.type === 'control') {
143
- if (node.word === 'red')
173
+ if (node.word === 'red') {
144
174
  r = node.param ?? 0;
145
- else if (node.word === 'green')
175
+ hasComponents = true;
176
+ }
177
+ else if (node.word === 'green') {
146
178
  g = node.param ?? 0;
147
- else if (node.word === 'blue')
179
+ hasComponents = true;
180
+ }
181
+ else if (node.word === 'blue') {
148
182
  b = node.param ?? 0;
183
+ hasComponents = true;
184
+ }
149
185
  }
150
186
  else if (node.type === 'text' && node.value.includes(';')) {
151
187
  // Each ';' terminates one color entry
152
188
  const semiCount = (node.value.match(/;/g) || []).length;
153
189
  for (let i = 0; i < semiCount; i++) {
154
- colors.push(`rgb(${r},${g},${b})`);
190
+ finalizeEntry();
155
191
  r = 0;
156
192
  g = 0;
157
193
  b = 0;
194
+ hasComponents = false;
158
195
  }
159
196
  }
160
197
  }
@@ -218,10 +255,10 @@ export function rtfToHtml(rtfString) {
218
255
  sub: false,
219
256
  fontSize: 24, // 12pt default
220
257
  colorIndex: 0,
258
+ highlightIndex: 0,
221
259
  fontIndex: 0,
222
260
  ucSkip: 1,
223
261
  inList: false,
224
- listType: null
225
262
  };
226
263
  }
227
264
  const stateStack = [defaultState()];
@@ -239,6 +276,11 @@ export function rtfToHtml(rtfString) {
239
276
  styles.push(`color:${color}`);
240
277
  }
241
278
  }
279
+ if (state.highlightIndex > 0) {
280
+ const bg = HIGHLIGHT_COLORS[state.highlightIndex];
281
+ if (bg)
282
+ styles.push(`background-color:${bg}`);
283
+ }
242
284
  const ptSize = state.fontSize / 2;
243
285
  if (ptSize && ptSize !== 12) {
244
286
  styles.push(`font-size:${ptSize}pt`);
@@ -361,6 +403,12 @@ export function rtfToHtml(rtfString) {
361
403
  if (p != null)
362
404
  state.colorIndex = p;
363
405
  break;
406
+ case 'cb':
407
+ break; // \cb not rendered on import
408
+ case 'highlight':
409
+ if (p != null)
410
+ state.highlightIndex = p;
411
+ break;
364
412
  case 'f':
365
413
  if (p != null)
366
414
  state.fontIndex = p;
@@ -412,6 +460,8 @@ export function rtfToHtml(rtfString) {
412
460
  state.sub = false;
413
461
  state.fontSize = 24;
414
462
  state.colorIndex = 0;
463
+ state.highlightIndex = 0;
464
+ state.fontIndex = 0;
415
465
  break;
416
466
  case 'page':
417
467
  flushParagraph();
@@ -524,9 +574,8 @@ export function rtfToHtml(rtfString) {
524
574
  rendered.push(`<p>${trimmed}</p>`);
525
575
  }
526
576
  const output = rendered.join('\n');
527
- // Suppress unused variable warnings
577
+ // fontTable is parsed (for future font rendering) but not yet used in output
528
578
  void fontTable;
529
- void paragraphHasText;
530
579
  return output || '<p></p>';
531
580
  }
532
581
  function stripSizeSpan(html) {
@@ -2,8 +2,12 @@
2
2
  * HTML to RTF converter
3
3
  *
4
4
  * Walks a contenteditable DOM tree and produces a valid RTF 1.x string.
5
- * Supports: bold, italic, underline, strikethrough, font sizes (headings),
6
- * foreground colors, paragraphs, bullet lists, numbered lists, blockquotes,
7
- * code blocks, links, horizontal rules, line breaks, and inline styles.
5
+ * Supports: bold, italic, underline, strikethrough, font sizes (h1–h3),
6
+ * foreground colors (\cf), paragraphs, bullet/numbered lists, blockquotes,
7
+ * code blocks, hyperlinks, images (as alt-text placeholders), horizontal
8
+ * rules, line breaks, and Unicode characters.
9
+ *
10
+ * Background color is intentionally not written — RTF highlight support
11
+ * is inconsistent across viewers (TextEdit uses \AppleHighlight, not \highlight).
8
12
  */
9
13
  export declare function htmlToRtf(editorEl: HTMLElement): string;
@@ -2,9 +2,13 @@
2
2
  * HTML to RTF converter
3
3
  *
4
4
  * Walks a contenteditable DOM tree and produces a valid RTF 1.x string.
5
- * Supports: bold, italic, underline, strikethrough, font sizes (headings),
6
- * foreground colors, paragraphs, bullet lists, numbered lists, blockquotes,
7
- * code blocks, links, horizontal rules, line breaks, and inline styles.
5
+ * Supports: bold, italic, underline, strikethrough, font sizes (h1–h3),
6
+ * foreground colors (\cf), paragraphs, bullet/numbered lists, blockquotes,
7
+ * code blocks, hyperlinks, images (as alt-text placeholders), horizontal
8
+ * rules, line breaks, and Unicode characters.
9
+ *
10
+ * Background color is intentionally not written — RTF highlight support
11
+ * is inconsistent across viewers (TextEdit uses \AppleHighlight, not \highlight).
8
12
  */
9
13
  // ── Color helpers ──
10
14
  function parseColor(colorStr) {
@@ -77,7 +81,9 @@ export function htmlToRtf(editorEl) {
77
81
  colors.forEach((c, i) => {
78
82
  colorIndex.set(colorKey(c), i + 1);
79
83
  });
80
- let colorTable = '{\\colortbl ;';
84
+ // Emit a leading auto/default color entry for broad RTF compatibility:
85
+ // \cf0 = auto, first explicit color starts at \cf1.
86
+ let colorTable = '{\\colortbl;';
81
87
  for (const c of colors) {
82
88
  colorTable += `\\red${c.r}\\green${c.g}\\blue${c.b};`;
83
89
  }
@@ -97,20 +103,20 @@ export function htmlToRtf(editorEl) {
97
103
  '}';
98
104
  return rtf;
99
105
  }
106
+ function addColor(colorStr, colorMap) {
107
+ if (!colorStr)
108
+ return;
109
+ const c = parseColor(colorStr);
110
+ if (c)
111
+ colorMap.set(colorKey(c), c);
112
+ }
100
113
  function collectColors(el, colorMap) {
101
- if (el.style?.color) {
102
- const c = parseColor(el.style.color);
103
- if (c)
104
- colorMap.set(colorKey(c), c);
105
- }
106
- if (el.style?.backgroundColor) {
107
- const c = parseColor(el.style.backgroundColor);
108
- if (c)
109
- colorMap.set(colorKey(c), c);
110
- }
111
- for (const child of el.children) {
114
+ addColor(el.style?.color, colorMap);
115
+ // <font color="..."> is produced by execCommand('foreColor')
116
+ if (el.tagName?.toLowerCase() === 'font')
117
+ addColor(el.getAttribute('color'), colorMap);
118
+ for (const child of el.children)
112
119
  collectColors(child, colorMap);
113
- }
114
120
  }
115
121
  function walkChildren(parent, ctx) {
116
122
  let rtf = '';
@@ -244,6 +250,13 @@ function walkChildren(parent, ctx) {
244
250
  rtf += `${colorPfx}${sizePfx}${walkChildren(el, ctx)}${sizesfx}${colorSfx}`;
245
251
  break;
246
252
  }
253
+ // <font color="..."> is produced by execCommand('foreColor') in contenteditable
254
+ case 'font': {
255
+ const colorPfx = getColorPrefix(el, ctx);
256
+ const colorSfx = colorPfx ? '\\cf0 ' : '';
257
+ rtf += `${colorPfx}${walkChildren(el, ctx)}${colorSfx}`;
258
+ break;
259
+ }
247
260
  default:
248
261
  rtf += walkChildren(el, ctx);
249
262
  break;
@@ -252,7 +265,9 @@ function walkChildren(parent, ctx) {
252
265
  return rtf;
253
266
  }
254
267
  function getColorPrefix(el, ctx) {
255
- const color = el.style?.color;
268
+ // Support both inline style.color (spans) and the color attribute (<font>)
269
+ const color = el.style?.color ||
270
+ (el.tagName.toLowerCase() === 'font' ? (el.getAttribute('color') ?? '') : '');
256
271
  if (!color)
257
272
  return '';
258
273
  const c = parseColor(color);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-rtf-editor",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "RTF viewer and rich-text editor components for Svelte 5",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/jcelano/svelte-rtf-editor#readme",
@@ -45,13 +45,13 @@
45
45
  },
46
46
  "devDependencies": {
47
47
  "@changesets/cli": "^2.30.0",
48
- "@sveltejs/adapter-auto": "^7.0.0",
49
- "@sveltejs/kit": "^2.50.2",
48
+ "@sveltejs/adapter-auto": "^7.0.1",
49
+ "@sveltejs/kit": "^2.53.4",
50
50
  "@sveltejs/package": "^2.5.7",
51
51
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
52
- "publint": "^0.3.17",
53
- "svelte": "^5.51.0",
54
- "svelte-check": "^4.4.2",
52
+ "publint": "^0.3.18",
53
+ "svelte": "^5.53.7",
54
+ "svelte-check": "^4.4.4",
55
55
  "typescript": "^5.9.3",
56
56
  "vite": "^7.3.1",
57
57
  "vitest": "^4.0.18"