@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.
- package/dist/upstart-editor-api.d.ts +79 -0
- package/dist/upstart-editor-api.d.ts.map +1 -0
- package/dist/upstart-editor-api.js +208 -0
- package/dist/upstart-editor-api.js.map +1 -0
- package/dist/vite-plugin-upstart-attrs.d.ts +3 -3
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +227 -25
- package/dist/vite-plugin-upstart-attrs.js.map +1 -1
- package/dist/vite-plugin-upstart-branding/plugin.d.ts +17 -0
- package/dist/vite-plugin-upstart-branding/plugin.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-branding/plugin.js +41 -0
- package/dist/vite-plugin-upstart-branding/plugin.js.map +1 -0
- package/dist/vite-plugin-upstart-branding/runtime.d.ts +10 -0
- package/dist/vite-plugin-upstart-branding/runtime.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-branding/runtime.js +118 -0
- package/dist/vite-plugin-upstart-branding/runtime.js.map +1 -0
- package/dist/vite-plugin-upstart-branding/types.d.ts +14 -0
- package/dist/vite-plugin-upstart-branding/types.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-branding/types.js +1 -0
- package/dist/vite-plugin-upstart-editor/plugin.d.ts +3 -3
- package/dist/vite-plugin-upstart-editor/plugin.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/plugin.js +3 -16
- package/dist/vite-plugin-upstart-editor/plugin.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +25 -11
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts +5 -0
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.js +16 -0
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts +2 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.js +42 -7
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts +6 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +423 -129
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +18 -10
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-theme.d.ts +3 -3
- package/dist/vite-plugin-upstart-theme.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-theme.js +1 -3
- package/dist/vite-plugin-upstart-theme.js.map +1 -1
- package/package.json +12 -4
- package/src/tests/upstart-editor-api.test.ts +98 -174
- package/src/tests/vite-plugin-upstart-attrs.test.ts +408 -105
- package/src/tests/vite-plugin-upstart-branding.test.ts +90 -0
- package/src/tests/vite-plugin-upstart-editor.test.ts +1 -2
- package/src/upstart-editor-api.ts +90 -29
- package/src/vite-plugin-upstart-attrs.ts +376 -38
- package/src/vite-plugin-upstart-branding/plugin.ts +59 -0
- package/src/vite-plugin-upstart-branding/runtime.ts +128 -0
- package/src/vite-plugin-upstart-branding/types.ts +10 -0
- package/src/vite-plugin-upstart-editor/plugin.ts +4 -19
- package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +25 -12
- package/src/vite-plugin-upstart-editor/runtime/error-handler.ts +12 -0
- package/src/vite-plugin-upstart-editor/runtime/hover-overlay.ts +1 -1
- package/src/vite-plugin-upstart-editor/runtime/index.ts +39 -5
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +518 -141
- package/src/vite-plugin-upstart-editor/runtime/types.ts +18 -4
- package/src/vite-plugin-upstart-theme.ts +0 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
|
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:
|
|
194
|
-
|
|
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:
|
|
197
|
-
|
|
198
|
-
|
|
302
|
+
onBlur: ({ editor: e }) => {
|
|
303
|
+
if (!hasChanged) return;
|
|
304
|
+
hasChanged = false;
|
|
305
|
+
saveText(hash, e.getHTML());
|
|
199
306
|
},
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
|
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
|
|
394
|
+
return "block-rich";
|
|
220
395
|
}
|
|
221
396
|
|
|
222
|
-
return
|
|
397
|
+
return "block-rich";
|
|
223
398
|
}
|
|
224
399
|
|
|
225
|
-
function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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;
|
|
353
|
-
"
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
});
|
|
776
|
+
editor.on("transaction", updateActiveStates);
|
|
777
|
+
updateActiveStates();
|
|
401
778
|
}
|