@upstart.gg/vite-plugins 0.0.39 → 0.0.41

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 (64) hide show
  1. package/dist/upstart-editor-api.d.ts +79 -0
  2. package/dist/upstart-editor-api.d.ts.map +1 -0
  3. package/dist/upstart-editor-api.js +208 -0
  4. package/dist/upstart-editor-api.js.map +1 -0
  5. package/dist/vite-plugin-upstart-attrs.d.ts +3 -3
  6. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  7. package/dist/vite-plugin-upstart-attrs.js +227 -25
  8. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  9. package/dist/vite-plugin-upstart-branding/plugin.d.ts +17 -0
  10. package/dist/vite-plugin-upstart-branding/plugin.d.ts.map +1 -0
  11. package/dist/vite-plugin-upstart-branding/plugin.js +41 -0
  12. package/dist/vite-plugin-upstart-branding/plugin.js.map +1 -0
  13. package/dist/vite-plugin-upstart-branding/runtime.d.ts +10 -0
  14. package/dist/vite-plugin-upstart-branding/runtime.d.ts.map +1 -0
  15. package/dist/vite-plugin-upstart-branding/runtime.js +118 -0
  16. package/dist/vite-plugin-upstart-branding/runtime.js.map +1 -0
  17. package/dist/vite-plugin-upstart-branding/types.d.ts +14 -0
  18. package/dist/vite-plugin-upstart-branding/types.d.ts.map +1 -0
  19. package/dist/vite-plugin-upstart-branding/types.js +1 -0
  20. package/dist/vite-plugin-upstart-editor/plugin.d.ts +3 -3
  21. package/dist/vite-plugin-upstart-editor/plugin.d.ts.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/plugin.js +3 -16
  23. package/dist/vite-plugin-upstart-editor/plugin.js.map +1 -1
  24. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +25 -11
  25. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  26. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts +5 -0
  27. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts.map +1 -0
  28. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js +16 -0
  29. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -0
  30. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +1 -1
  31. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -1
  32. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts +2 -1
  33. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  34. package/dist/vite-plugin-upstart-editor/runtime/index.js +42 -7
  35. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  36. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts +6 -1
  37. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
  38. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +423 -129
  39. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  40. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +18 -10
  41. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  42. package/dist/vite-plugin-upstart-theme.d.ts +3 -3
  43. package/dist/vite-plugin-upstart-theme.d.ts.map +1 -1
  44. package/dist/vite-plugin-upstart-theme.js +1 -3
  45. package/dist/vite-plugin-upstart-theme.js.map +1 -1
  46. package/package.json +12 -4
  47. package/src/tests/upstart-editor-api.test.ts +98 -174
  48. package/src/tests/vite-plugin-upstart-attrs.test.ts +408 -105
  49. package/src/tests/vite-plugin-upstart-branding.test.ts +90 -0
  50. package/src/tests/vite-plugin-upstart-editor.test.ts +1 -2
  51. package/src/upstart-editor-api.ts +90 -29
  52. package/src/vite-plugin-upstart-attrs.ts +376 -38
  53. package/src/vite-plugin-upstart-branding/plugin.ts +59 -0
  54. package/src/vite-plugin-upstart-branding/runtime.ts +128 -0
  55. package/src/vite-plugin-upstart-branding/types.ts +10 -0
  56. package/src/vite-plugin-upstart-editor/plugin.ts +4 -19
  57. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +25 -12
  58. package/src/vite-plugin-upstart-editor/runtime/error-handler.ts +12 -0
  59. package/src/vite-plugin-upstart-editor/runtime/hover-overlay.ts +1 -1
  60. package/src/vite-plugin-upstart-editor/runtime/index.ts +39 -5
  61. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +518 -141
  62. package/src/vite-plugin-upstart-editor/runtime/types.ts +18 -4
  63. package/src/vite-plugin-upstart-theme.ts +0 -3
  64. package/src/vite-plugin-upstart-editor/PLAN.md +0 -1391
@@ -1,53 +1,159 @@
1
- import { Editor } from "@tiptap/core";
2
- import type { Extension } from "@tiptap/core";
1
+ import { Editor, Extension, Node } from "@tiptap/core";
3
2
  import { BubbleMenu } from "@tiptap/extension-bubble-menu";
4
3
  import Placeholder from "@tiptap/extension-placeholder";
5
4
  import StarterKit from "@tiptap/starter-kit";
6
- import { getCurrentMode } from "./index.js";
7
5
  import { sendToParent } from "./utils.js";
8
- import type { EditorInstance, UpstartEditorOptions } from "./types.js";
6
+ import type { EditorInstance, TextEditorMode, UpstartEditorOptions } from "./types.js";
7
+
8
+ /**
9
+ * Custom Document node for inline elements (e.g. <a>, <span>, headings).
10
+ * Uses `inline*` content instead of the default `block+` to prevent
11
+ * TipTap from wrapping text in <p> tags inside inline elements.
12
+ */
13
+ const InlineDocument = Node.create({
14
+ name: "doc",
15
+ topNode: true,
16
+ content: "inline*",
17
+ });
18
+
19
+ /**
20
+ * Remaps Enter to insert a <br> (hard break) instead of creating a new paragraph.
21
+ * Used in inline-rich mode where block nodes are not allowed.
22
+ */
23
+ const EnterHardBreak = Extension.create({
24
+ name: "enterHardBreak",
25
+ addKeyboardShortcuts() {
26
+ return {
27
+ Enter: () => this.editor.commands.setHardBreak(),
28
+ };
29
+ },
30
+ });
9
31
 
10
32
  const DEFAULT_OPTIONS: Required<UpstartEditorOptions> = {
11
33
  richTextElements: ["p", "div", "article", "section"],
12
- plainTextElements: ["button", "a", "span", "h1", "h2", "h3", "h4", "h5", "h6", "label"],
34
+ inlineRichTextElements: ["a", "span", "h1", "h2", "h3", "h4", "h5", "h6"],
35
+ plainTextElements: ["button", "label"],
13
36
  bubbleMenu: true,
14
37
  placeholder: "Start typing...",
15
38
  autoSaveDelay: 1000,
16
39
  };
17
40
 
18
41
  const activeEditors = new Map<string, EditorInstance>();
19
- const saveTimeouts = new Map<string, number>();
42
+ let i18nSyncInProgress = false;
20
43
  const styleCache = new WeakMap<
21
44
  HTMLElement,
22
45
  { outline: string; outlineOffset: string; cursor: string; transition: string }
23
46
  >();
24
- let isInitialized = false;
47
+ let resolvedOptions: Required<UpstartEditorOptions> = DEFAULT_OPTIONS;
25
48
  let shortcutsRegistered = false;
49
+ let domObserver: MutationObserver | null = null;
50
+ let reactivateTimer: number | null = null;
26
51
 
27
52
  /**
28
53
  * Initialize TipTap text editing for elements marked as editable.
54
+ * Activation is deferred to avoid conflicting with React hydration.
29
55
  */
30
56
  export function initTextEditor(options: UpstartEditorOptions = {}): void {
31
57
  if (typeof window === "undefined") {
58
+ console.warn("[Upstart Editor] Cannot initialize text editor: not running in a browser environment");
32
59
  return;
33
60
  }
34
61
 
35
- if (isInitialized) {
36
- return;
62
+ resolvedOptions = { ...DEFAULT_OPTIONS, ...options };
63
+ registerKeyboardShortcuts();
64
+
65
+ // Defer activation to let React finish hydrating the server-rendered HTML.
66
+ waitForHydration(() => {
67
+ injectEditorStyles();
68
+ // activateAllEditors();
69
+ startDomObserver();
70
+ });
71
+ }
72
+
73
+ function waitForHydration(callback: () => void): void {
74
+ // Remix/React 18 wraps hydrateRoot in startTransition, making hydration a
75
+ // concurrent (low-priority) operation that can span many frames after the
76
+ // load event. Instead of guessing a delay, we wait for the DOM to stabilise:
77
+ // once 200 ms pass without any childList mutations, hydration is done.
78
+ const STABILITY_MS = 200;
79
+
80
+ const onReady = () => {
81
+ let timer: number | null = null;
82
+
83
+ const settle = () => {
84
+ if (timer) clearTimeout(timer);
85
+ timer = window.setTimeout(() => {
86
+ observer.disconnect();
87
+ callback();
88
+ }, STABILITY_MS);
89
+ };
90
+
91
+ const observer = new MutationObserver(settle);
92
+ observer.observe(document.documentElement, { childList: true, subtree: true });
93
+
94
+ // Kick off the first timer (covers case where no mutations occur after load)
95
+ settle();
96
+ };
97
+
98
+ if (document.readyState === "complete") {
99
+ onReady();
100
+ } else {
101
+ window.addEventListener("load", onReady, { once: true });
37
102
  }
103
+ }
38
104
 
105
+ /**
106
+ * Inject CSS rules that visually hide original content when an editor is active.
107
+ * This avoids moving DOM nodes (which breaks React hydration).
108
+ *
109
+ * - `font-size: 0` + `color: transparent` hides direct text nodes
110
+ * - `> *:not(.ProseMirror)` hides child elements
111
+ * - ProseMirror gets explicit inline styles to restore text rendering
112
+ */
113
+ function injectEditorStyles(): void {
114
+ if (document.getElementById("upstart-editor-styles")) return;
115
+
116
+ const style = document.createElement("style");
117
+ style.id = "upstart-editor-styles";
118
+ style.textContent = [
119
+ "[data-upstart-editor-active] {",
120
+ " cursor: text;",
121
+ " transition: outline 100ms;",
122
+ "}",
123
+ "[data-upstart-editor-active]:hover {",
124
+ " outline: 1px solid #7270c6;",
125
+ " outline-offset: 4px;",
126
+ "}",
127
+ // "[data-upstart-editor-active] > *:not(.ProseMirror) {",
128
+ // " display: none !important;",
129
+ // "}",
130
+ "[data-upstart-editor-active] .ProseMirror {",
131
+ " outline: none !important;",
132
+ " font-size: inherit !important;",
133
+ " line-height: inherit !important;",
134
+ " color: inherit !important;",
135
+ " letter-spacing: inherit !important;",
136
+ " font-weight: inherit !important;",
137
+ " white-space: inherit !important;",
138
+ "}",
139
+ "[data-upstart-editor-active] .ProseMirror:focus {",
140
+ " outline: none !important;",
141
+ "}",
142
+ ].join("\n");
143
+ document.head.appendChild(style);
144
+ }
145
+
146
+ /**
147
+ * Activate editors on all editable elements. Safe to call multiple times.
148
+ */
149
+ export function activateAllEditors(): void {
39
150
  try {
40
- const resolvedOptions = { ...DEFAULT_OPTIONS, ...options };
151
+ cleanupOrphanedEditors();
41
152
  const editables = document.querySelectorAll<HTMLElement>('[data-upstart-editable-text="true"]');
42
-
43
153
  editables.forEach((element) => setupEditableElement(element, resolvedOptions));
44
-
45
- registerKeyboardShortcuts();
46
- isInitialized = true;
47
-
48
- console.log("[Upstart Editor] Text editor initialized");
154
+ console.log("[Upstart Editor] Text editors activated");
49
155
  } catch (error) {
50
- console.error("[Upstart Editor] Failed to initialize text editor:", error);
156
+ console.error("[Upstart Editor] Failed to activate text editors:", error);
51
157
  sendToParent({
52
158
  type: "editor-error",
53
159
  error: error instanceof Error ? error.message : "Unknown error",
@@ -59,6 +165,7 @@ export function initTextEditor(options: UpstartEditorOptions = {}): void {
59
165
  * Destroy all active editors.
60
166
  */
61
167
  export function destroyAllActiveEditors(): void {
168
+ stopDomObserver();
62
169
  for (const hash of activeEditors.keys()) {
63
170
  destroyEditor(hash);
64
171
  }
@@ -66,53 +173,37 @@ export function destroyAllActiveEditors(): void {
66
173
 
67
174
  function setupEditableElement(element: HTMLElement, options: Required<UpstartEditorOptions>): void {
68
175
  const hash = getEditableHash(element);
69
- if (!hash) {
176
+ if (!hash || activeEditors.has(hash)) {
70
177
  return;
71
178
  }
72
179
 
73
180
  cacheStyles(element);
74
- element.style.transition = "outline 0.15s ease";
75
-
76
- element.addEventListener("dblclick", (event) => {
77
- if (getCurrentMode() !== "edit") {
78
- return;
79
- }
80
-
81
- event.preventDefault();
82
- event.stopPropagation();
83
-
84
- if (activeEditors.has(hash)) {
85
- return;
86
- }
87
-
88
- activateEditor(element, hash, options);
89
- });
90
-
91
- element.addEventListener("mouseenter", () => {
92
- if (getCurrentMode() !== "edit") {
93
- return;
94
- }
95
-
96
- if (!activeEditors.has(hash)) {
97
- applyHoverStyles(element);
98
- }
99
- });
100
-
101
- element.addEventListener("mouseleave", () => {
102
- if (!activeEditors.has(hash)) {
103
- restoreStyles(element);
104
- }
105
- });
181
+ activateEditor(element, hash, options);
106
182
  }
107
183
 
108
184
  function activateEditor(element: HTMLElement, hash: string, options: Required<UpstartEditorOptions>): void {
109
- const isRichText = shouldUseRichText(element, options);
110
- const editor = isRichText
111
- ? createRichTextEditor(element, hash, options)
112
- : createPlainTextEditor(element, hash, options);
185
+ const mode = getEditorMode(element, options);
186
+
187
+ let editor: Editor;
188
+ switch (mode) {
189
+ case "block-rich":
190
+ editor = createRichTextEditor(element, hash, options);
191
+ break;
192
+ case "inline-rich":
193
+ editor = createInlineRichTextEditor(element, hash, options);
194
+ break;
195
+ case "plain":
196
+ editor = createPlainTextEditor(element, hash, options);
197
+ break;
198
+ }
199
+
200
+ // Restore text rendering on ProseMirror (overrides inherited font-size: 0 etc.)
201
+
202
+ // Mark element — triggers CSS rules that hide original content
203
+ element.dataset.upstartEditorActive = "true";
113
204
 
114
205
  applyActiveStyles(element);
115
- activeEditors.set(hash, { editor, element, hash });
206
+ activeEditors.set(hash, { editor, element, hash, mode });
116
207
  }
117
208
 
118
209
  function createPlainTextEditor(
@@ -120,10 +211,15 @@ function createPlainTextEditor(
120
211
  hash: string,
121
212
  options: Required<UpstartEditorOptions>,
122
213
  ): Editor {
123
- return new Editor({
214
+ const content = element.textContent ?? "";
215
+ let hasChanged = false;
216
+
217
+ const editor = new Editor({
124
218
  element,
125
219
  extensions: [
220
+ InlineDocument,
126
221
  StarterKit.configure({
222
+ document: false,
127
223
  heading: false,
128
224
  bold: false,
129
225
  italic: false,
@@ -139,24 +235,27 @@ function createPlainTextEditor(
139
235
  placeholder: "Click to edit...",
140
236
  }),
141
237
  ],
142
- content: element.textContent ?? "",
238
+ content,
143
239
  editorProps: {
144
240
  attributes: {
145
241
  class: "upstart-editor-active",
146
- style: "outline: 2px solid #3b82f6; outline-offset: 2px;",
147
242
  },
148
243
  },
149
- onUpdate: ({ editor }) => {
150
- debouncedSave(hash, editor.getText(), options.autoSaveDelay);
151
- },
152
- onBlur: ({ editor }) => {
153
- saveText(hash, editor.getText());
154
- destroyEditor(hash);
244
+ onUpdate: ({ editor: e }) => {
245
+ if (i18nSyncInProgress) return;
246
+ hasChanged = true;
247
+ // const content = e.getText();
248
+ // debouncedSave(hash, content, options.autoSaveDelay);
249
+ syncI18nSiblings(hash);
155
250
  },
156
- onCreate: ({ editor }) => {
157
- editor.commands.focus("end");
251
+ onBlur: ({ editor: e }) => {
252
+ if (!hasChanged) return;
253
+ hasChanged = false;
254
+ saveText(hash, e.getText());
158
255
  },
159
256
  });
257
+
258
+ return editor;
160
259
  }
161
260
 
162
261
  function createRichTextEditor(
@@ -164,6 +263,10 @@ function createRichTextEditor(
164
263
  hash: string,
165
264
  options: Required<UpstartEditorOptions>,
166
265
  ): Editor {
266
+ const content = element.innerHTML;
267
+ element.innerHTML = ""; // Clear the element first!
268
+ let hasChanged = false;
269
+
167
270
  const bubbleMenuElement = createBubbleMenuElement();
168
271
  const extensions: Extension[] = [
169
272
  StarterKit,
@@ -183,68 +286,176 @@ function createRichTextEditor(
183
286
  const editor = new Editor({
184
287
  element,
185
288
  extensions,
186
- content: element.innerHTML,
289
+ content,
187
290
  editorProps: {
188
291
  attributes: {
189
292
  class: "upstart-editor-active",
190
- style: "outline: 2px solid #3b82f6; outline-offset: 2px;",
191
293
  },
192
294
  },
193
- onUpdate: ({ editor: updatedEditor }) => {
194
- debouncedSave(hash, updatedEditor.getHTML(), options.autoSaveDelay);
295
+ onUpdate: ({ editor: e }) => {
296
+ if (i18nSyncInProgress) return;
297
+ hasChanged = true;
298
+ // const content = e.getHTML();
299
+ // debouncedSave(hash, content, options.autoSaveDelay);
300
+ syncI18nSiblings(hash);
195
301
  },
196
- onBlur: ({ editor: updatedEditor }) => {
197
- saveText(hash, updatedEditor.getHTML());
198
- destroyEditor(hash);
302
+ onBlur: ({ editor: e }) => {
303
+ if (!hasChanged) return;
304
+ hasChanged = false;
305
+ saveText(hash, e.getHTML());
199
306
  },
200
- onCreate: ({ editor: createdEditor }) => {
201
- if (options.bubbleMenu) {
202
- wireBubbleMenu(bubbleMenuElement, createdEditor);
203
- }
204
- createdEditor.commands.focus("end");
307
+ });
308
+
309
+ if (options.bubbleMenu) {
310
+ wireBubbleMenu(bubbleMenuElement, editor);
311
+ }
312
+
313
+ return editor;
314
+ }
315
+
316
+ function createInlineRichTextEditor(
317
+ element: HTMLElement,
318
+ hash: string,
319
+ options: Required<UpstartEditorOptions>,
320
+ ): Editor {
321
+ const content = element.innerHTML;
322
+ element.innerHTML = ""; // Clear the element first!
323
+ let hasChanged = false;
324
+
325
+ const bubbleMenuElement = createBubbleMenuElement("inline");
326
+ const extensions: (Extension | ReturnType<typeof Node.create>)[] = [
327
+ InlineDocument,
328
+ EnterHardBreak,
329
+ StarterKit.configure({
330
+ document: false,
331
+ heading: false,
332
+ blockquote: false,
333
+ bulletList: false,
334
+ orderedList: false,
335
+ listItem: false,
336
+ codeBlock: false,
337
+ horizontalRule: false,
338
+ }),
339
+ Placeholder.configure({
340
+ placeholder: "Click to edit...",
341
+ }),
342
+ ];
343
+
344
+ if (options.bubbleMenu) {
345
+ extensions.push(
346
+ BubbleMenu.configure({
347
+ element: bubbleMenuElement,
348
+ }),
349
+ );
350
+ }
351
+
352
+ const editor = new Editor({
353
+ element,
354
+ extensions,
355
+ content,
356
+ editorProps: {
357
+ attributes: {
358
+ class: "upstart-editor-active",
359
+ },
360
+ },
361
+ onUpdate: ({ editor: e }) => {
362
+ if (i18nSyncInProgress) return;
363
+ hasChanged = true;
364
+ // const content = e.getHTML();
365
+ // debouncedSave(hash, content, options.autoSaveDelay);
366
+ syncI18nSiblings(hash);
367
+ },
368
+ onBlur: ({ editor: e }) => {
369
+ if (!hasChanged) return;
370
+ hasChanged = false;
371
+ saveText(hash, e.getHTML());
205
372
  },
206
373
  });
207
374
 
375
+ if (options.bubbleMenu) {
376
+ wireBubbleMenu(bubbleMenuElement, editor);
377
+ }
378
+
208
379
  return editor;
209
380
  }
210
381
 
211
- function shouldUseRichText(element: HTMLElement, options: Required<UpstartEditorOptions>): boolean {
382
+ function getEditorMode(element: HTMLElement, options: Required<UpstartEditorOptions>): TextEditorMode {
212
383
  const tagName = element.tagName.toLowerCase();
213
384
 
214
385
  if (options.plainTextElements.includes(tagName)) {
215
- return false;
386
+ return "plain";
387
+ }
388
+
389
+ if (options.inlineRichTextElements.includes(tagName)) {
390
+ return "inline-rich";
216
391
  }
217
392
 
218
393
  if (options.richTextElements.includes(tagName)) {
219
- return true;
394
+ return "block-rich";
220
395
  }
221
396
 
222
- return true;
397
+ return "block-rich";
223
398
  }
224
399
 
225
- function debouncedSave(hash: string, content: string, delay: number): void {
226
- const existingTimeout = saveTimeouts.get(hash);
227
- if (existingTimeout) {
228
- clearTimeout(existingTimeout);
400
+ function syncI18nSiblings(sourceHash: string): void {
401
+ console.log("[Upstart Editor] Syncing i18n siblings for", sourceHash);
402
+ const instance = activeEditors.get(sourceHash);
403
+ if (!instance) {
404
+ console.warn("[Upstart Editor] No editor instance found for hash:", sourceHash);
405
+ return;
229
406
  }
230
407
 
231
- const timeoutId = window.setTimeout(() => {
232
- sendToParent({
233
- type: "text-update",
234
- hash,
235
- newText: content,
236
- });
237
- }, delay);
408
+ const i18nKey = instance.element.dataset.upstartI18n;
409
+ if (!i18nKey) {
410
+ console.warn("[Upstart Editor] No sibling i18n key found for element:", instance.element);
411
+ return;
412
+ }
413
+
414
+ const siblings = document.querySelectorAll<HTMLElement>(`[data-upstart-i18n="${CSS.escape(i18nKey)}"]`);
415
+ console.log(`[Upstart Editor] Found ${siblings.length} sibling(s) with i18n key "${i18nKey}"`);
416
+
417
+ // Always use plain text so each sibling's schema can wrap it correctly
418
+ // (e.g. InlineDocument vs block Document have incompatible content rules).
419
+ const plainContent = instance.editor.getText();
238
420
 
239
- saveTimeouts.set(hash, timeoutId);
421
+ i18nSyncInProgress = true;
422
+ try {
423
+ for (const sibling of siblings) {
424
+ if (sibling === instance.element) continue;
425
+
426
+ const siblingHash = sibling.dataset.upstartHash;
427
+ const siblingInstance = siblingHash ? activeEditors.get(siblingHash) : null;
428
+
429
+ if (siblingInstance) {
430
+ console.log(
431
+ `[Upstart Editor] Updating sibling editor (hash: ${siblingHash}) with new content`,
432
+ siblingInstance,
433
+ );
434
+ sibling.innerText = plainContent;
435
+ siblingInstance.editor.chain().selectAll().insertContent(plainContent).run();
436
+ } else {
437
+ sibling.textContent = plainContent;
438
+ }
439
+ }
440
+ } finally {
441
+ i18nSyncInProgress = false;
442
+ }
240
443
  }
241
444
 
242
445
  function saveText(hash: string, newText: string): void {
243
446
  try {
447
+ const instance = activeEditors.get(hash);
448
+ const dataset = instance?.element.dataset ?? {};
449
+ const [namespace, key] = dataset.upstartI18n?.split(":") ?? [];
244
450
  sendToParent({
245
- type: "text-save",
246
- hash,
247
- newText,
451
+ type: "text-edit",
452
+ payload: {
453
+ action: "editText",
454
+ content: newText,
455
+ namespace,
456
+ key,
457
+ language: document.documentElement.lang,
458
+ },
248
459
  });
249
460
 
250
461
  console.log("[Upstart Editor] Text save message sent:", hash);
@@ -263,17 +474,112 @@ function destroyEditor(hash: string): void {
263
474
  return;
264
475
  }
265
476
 
477
+ const finalContent = instance.mode === "plain" ? instance.editor.getText() : instance.editor.getHTML();
478
+
266
479
  instance.editor.destroy();
480
+
481
+ // Remove ProseMirror DOM
482
+ const editorDom = instance.element.querySelector(".ProseMirror");
483
+ if (editorDom) editorDom.remove();
484
+
485
+ // Remove CSS-hiding marker — original content becomes visible again
486
+ delete instance.element.dataset.upstartEditorActive;
487
+
488
+ // Update element content with the final edited text
489
+ if (instance.mode === "plain") {
490
+ instance.element.textContent = finalContent;
491
+ } else {
492
+ instance.element.innerHTML = finalContent;
493
+ }
494
+
267
495
  restoreStyles(instance.element);
268
496
  activeEditors.delete(hash);
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // MutationObserver — re-activates editors after React re-renders
501
+ // ---------------------------------------------------------------------------
502
+
503
+ function startDomObserver(): void {
504
+ if (domObserver) return;
505
+
506
+ domObserver = new MutationObserver((mutations) => {
507
+ let needsReactivation = false;
508
+
509
+ for (const mutation of mutations) {
510
+ if (mutation.type !== "childList") continue;
511
+
512
+ for (const node of mutation.addedNodes) {
513
+ if (
514
+ node instanceof HTMLElement &&
515
+ (node.matches?.('[data-upstart-editable-text="true"]') ||
516
+ node.querySelector?.('[data-upstart-editable-text="true"]'))
517
+ ) {
518
+ needsReactivation = true;
519
+ break;
520
+ }
521
+ }
269
522
 
270
- const existingTimeout = saveTimeouts.get(hash);
271
- if (existingTimeout) {
272
- clearTimeout(existingTimeout);
273
- saveTimeouts.delete(hash);
523
+ if (!needsReactivation) {
524
+ for (const node of mutation.removedNodes) {
525
+ if (
526
+ node instanceof HTMLElement &&
527
+ (node.matches?.('[data-upstart-editable-text="true"]') ||
528
+ node.querySelector?.('[data-upstart-editable-text="true"]'))
529
+ ) {
530
+ needsReactivation = true;
531
+ break;
532
+ }
533
+ }
534
+ }
535
+
536
+ if (needsReactivation) break;
537
+ }
538
+
539
+ if (needsReactivation) {
540
+ scheduleReactivation();
541
+ }
542
+ });
543
+
544
+ domObserver.observe(document.body, { childList: true, subtree: true });
545
+ }
546
+
547
+ function stopDomObserver(): void {
548
+ if (domObserver) {
549
+ domObserver.disconnect();
550
+ domObserver = null;
551
+ }
552
+ if (reactivateTimer) {
553
+ clearTimeout(reactivateTimer);
554
+ reactivateTimer = null;
274
555
  }
275
556
  }
276
557
 
558
+ function scheduleReactivation(): void {
559
+ if (reactivateTimer) clearTimeout(reactivateTimer);
560
+ reactivateTimer = window.setTimeout(() => {
561
+ reactivateTimer = null;
562
+ activateAllEditors();
563
+ }, 50);
564
+ }
565
+
566
+ /**
567
+ * Remove editors whose host element has been detached from the document
568
+ * (e.g. React replaced the subtree during a re-render).
569
+ */
570
+ function cleanupOrphanedEditors(): void {
571
+ for (const [hash, instance] of activeEditors) {
572
+ if (!document.contains(instance.element)) {
573
+ instance.editor.destroy();
574
+ activeEditors.delete(hash);
575
+ }
576
+ }
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Keyboard shortcuts
581
+ // ---------------------------------------------------------------------------
582
+
277
583
  function registerKeyboardShortcuts(): void {
278
584
  if (shortcutsRegistered) {
279
585
  return;
@@ -298,6 +604,10 @@ function blurAllEditors(): void {
298
604
  });
299
605
  }
300
606
 
607
+ // ---------------------------------------------------------------------------
608
+ // Helpers
609
+ // ---------------------------------------------------------------------------
610
+
301
611
  function getEditableHash(element: HTMLElement): string | null {
302
612
  return element.dataset.upstartHash ?? null;
303
613
  }
@@ -315,18 +625,8 @@ function cacheStyles(element: HTMLElement): void {
315
625
  });
316
626
  }
317
627
 
318
- function applyHoverStyles(element: HTMLElement): void {
319
- cacheStyles(element);
320
- element.style.outline = "1px dashed #3b82f6";
321
- element.style.outlineOffset = "2px";
322
- element.style.cursor = "text";
323
- }
324
-
325
628
  function applyActiveStyles(element: HTMLElement): void {
326
629
  cacheStyles(element);
327
- element.style.outline = "2px solid #3b82f6";
328
- element.style.outlineOffset = "2px";
329
- element.style.cursor = "text";
330
630
  }
331
631
 
332
632
  function restoreStyles(element: HTMLElement): void {
@@ -345,28 +645,73 @@ function restoreStyles(element: HTMLElement): void {
345
645
  element.style.transition = cached.transition;
346
646
  }
347
647
 
348
- function createBubbleMenuElement(): HTMLDivElement {
648
+ const BUBBLE_MENU_GROUPS: { command: string; icon: string; title: string }[][] = [
649
+ [
650
+ { command: "bold", icon: "format-bold", title: "Bold" },
651
+ { command: "italic", icon: "format-italic", title: "Italic" },
652
+ { command: "strike", icon: "format-strikethrough", title: "Strikethrough" },
653
+ { command: "code", icon: "code-tags", title: "Inline Code" },
654
+ ],
655
+ [
656
+ { command: "heading", icon: "format-header-1", title: "Heading" },
657
+ { command: "blockquote", icon: "format-quote-close", title: "Blockquote" },
658
+ ],
659
+ [
660
+ { command: "bulletList", icon: "format-list-bulleted", title: "Bullet List" },
661
+ { command: "orderedList", icon: "format-list-numbered", title: "Ordered List" },
662
+ ],
663
+ ];
664
+
665
+ function getIconifyUrl(iconName: string): string {
666
+ return `https://api.iconify.design/mdi/${iconName}.svg?height=none&color=%23fff`;
667
+ }
668
+
669
+ function createBubbleMenuElement(mode: "block" | "inline" = "block"): HTMLDivElement {
670
+ const groups = mode === "inline" ? [BUBBLE_MENU_GROUPS[0]] : BUBBLE_MENU_GROUPS;
349
671
  const menu = document.createElement("div");
350
672
  menu.className = "upstart-editor-bubble-menu";
351
673
  menu.style.cssText =
352
- "display: flex; gap: 6px; padding: 6px 8px; background: #111827; color: #ffffff; " +
353
- "border-radius: 6px; font-size: 12px; box-shadow: 0 6px 16px rgba(0,0,0,0.2);";
354
-
355
- const buttons = [
356
- { label: "B", command: "bold" },
357
- { label: "I", command: "italic" },
358
- { label: "S", command: "strike" },
359
- ];
674
+ "display: flex; align-items: center; gap: 2px; padding: 4px; " +
675
+ "background: #111827; color: #ffffff; border-radius: 8px; " +
676
+ "font-size: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.25);";
677
+
678
+ groups.forEach((group, groupIndex) => {
679
+ if (groupIndex > 0) {
680
+ const separator = document.createElement("div");
681
+ separator.style.cssText =
682
+ "width: 1px; height: 16px; background: rgba(255,255,255,0.2); " + "margin: 0 4px; flex-shrink: 0;";
683
+ menu.appendChild(separator);
684
+ }
360
685
 
361
- for (const { label, command } of buttons) {
362
- const button = document.createElement("button");
363
- button.type = "button";
364
- button.textContent = label;
365
- button.dataset.command = command;
366
- button.style.cssText =
367
- "background: transparent; border: none; color: inherit; cursor: pointer; font-weight: 600;";
368
- menu.appendChild(button);
369
- }
686
+ for (const { command, icon, title } of group) {
687
+ const button = document.createElement("button");
688
+ button.type = "button";
689
+ button.dataset.command = command;
690
+ button.title = title;
691
+ button.style.cssText =
692
+ "background: transparent; border: none; color: inherit; cursor: pointer; " +
693
+ "display: flex; align-items: center; justify-content: center; " +
694
+ "width: 28px; height: 28px; padding: 4px; border-radius: 4px; " +
695
+ "transition: background 0.15s ease;";
696
+
697
+ const img = document.createElement("img");
698
+ img.src = getIconifyUrl(icon);
699
+ img.alt = title;
700
+ img.style.cssText = "width: 18px; height: 18px; display: block; pointer-events: none;";
701
+ button.appendChild(img);
702
+
703
+ button.addEventListener("mouseenter", () => {
704
+ if (!button.dataset.active) {
705
+ button.style.background = "rgba(255,255,255,0.12)";
706
+ }
707
+ });
708
+ button.addEventListener("mouseleave", () => {
709
+ button.style.background = button.dataset.active ? "rgba(255,255,255,0.2)" : "transparent";
710
+ });
711
+
712
+ menu.appendChild(button);
713
+ }
714
+ });
370
715
 
371
716
  return menu;
372
717
  }
@@ -379,23 +724,55 @@ function wireBubbleMenu(menu: HTMLDivElement, editor: Editor): void {
379
724
  menu.addEventListener("click", (event) => {
380
725
  const target = event.target as HTMLElement | null;
381
726
  const command = target?.dataset.command;
382
-
383
- if (!command) {
384
- return;
385
- }
727
+ if (!command) return;
386
728
 
387
729
  const chain = editor.chain().focus();
388
730
 
389
- if (command === "bold") {
390
- chain.toggleBold().run();
731
+ switch (command) {
732
+ case "bold":
733
+ chain.toggleBold().run();
734
+ break;
735
+ case "italic":
736
+ chain.toggleItalic().run();
737
+ break;
738
+ case "strike":
739
+ chain.toggleStrike().run();
740
+ break;
741
+ case "code":
742
+ chain.toggleCode().run();
743
+ break;
744
+ case "heading":
745
+ chain.toggleHeading({ level: 1 }).run();
746
+ break;
747
+ case "blockquote":
748
+ chain.toggleBlockquote().run();
749
+ break;
750
+ case "bulletList":
751
+ chain.toggleBulletList().run();
752
+ break;
753
+ case "orderedList":
754
+ chain.toggleOrderedList().run();
755
+ break;
391
756
  }
757
+ });
392
758
 
393
- if (command === "italic") {
394
- chain.toggleItalic().run();
759
+ const updateActiveStates = (): void => {
760
+ const buttons = menu.querySelectorAll<HTMLButtonElement>("button[data-command]");
761
+ for (const button of buttons) {
762
+ const command = button.dataset.command!;
763
+ const isActive =
764
+ command === "heading" ? editor.isActive("heading", { level: 1 }) : editor.isActive(command);
765
+
766
+ if (isActive) {
767
+ button.dataset.active = "true";
768
+ button.style.background = "rgba(255,255,255,0.2)";
769
+ } else {
770
+ delete button.dataset.active;
771
+ button.style.background = "transparent";
772
+ }
395
773
  }
774
+ };
396
775
 
397
- if (command === "strike") {
398
- chain.toggleStrike().run();
399
- }
400
- });
776
+ editor.on("transaction", updateActiveStates);
777
+ updateActiveStates();
401
778
  }