@cubud/wen 1.0.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/LICENSE +21 -0
- package/LLM_CONTRIBUTING.md +53 -0
- package/README.md +99 -0
- package/dist/wen-full.css +654 -0
- package/dist/wen-full.js +1007 -0
- package/dist/wen-full.min.css +1 -0
- package/dist/wen-full.min.js +96 -0
- package/package.json +49 -0
- package/src/wen-chart.css +33 -0
- package/src/wen-chart.js +148 -0
- package/src/wen-component.css +120 -0
- package/src/wen-component.js +239 -0
- package/src/wen-core.css +351 -0
- package/src/wen-core.js +363 -0
- package/src/wen-mermaid.css +50 -0
- package/src/wen-mermaid.js +125 -0
- package/src/wen-utils.js +22 -0
- package/src/wen-yaml.css +104 -0
- package/src/wen-yaml.js +293 -0
package/src/wen-core.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { Editor } from 'https://esm.sh/@tiptap/core@2.2.4';
|
|
2
|
+
import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2.2.4';
|
|
3
|
+
import { Markdown } from 'https://esm.sh/tiptap-markdown@0.8.9';
|
|
4
|
+
import Link from 'https://esm.sh/@tiptap/extension-link@2.2.4';
|
|
5
|
+
import Image from 'https://esm.sh/@tiptap/extension-image@2.2.4';
|
|
6
|
+
|
|
7
|
+
import CodeBlockLowlight from 'https://esm.sh/@tiptap/extension-code-block-lowlight@2.2.4';
|
|
8
|
+
import { createLowlight, common } from 'https://esm.sh/lowlight@3.1.0';
|
|
9
|
+
|
|
10
|
+
// 1. New Table Imports
|
|
11
|
+
import Table from 'https://esm.sh/@tiptap/extension-table@2.2.4';
|
|
12
|
+
import TableRow from 'https://esm.sh/@tiptap/extension-table-row@2.2.4';
|
|
13
|
+
import TableHeader from 'https://esm.sh/@tiptap/extension-table-header@2.2.4';
|
|
14
|
+
import TableCell from 'https://esm.sh/@tiptap/extension-table-cell@2.2.4';
|
|
15
|
+
|
|
16
|
+
import TaskList from 'https://esm.sh/@tiptap/extension-task-list@2.2.4';
|
|
17
|
+
import TaskItem from 'https://esm.sh/@tiptap/extension-task-item@2.2.4';
|
|
18
|
+
|
|
19
|
+
// ... existing imports ...
|
|
20
|
+
import { Extension } from 'https://esm.sh/@tiptap/core@2.2.4';
|
|
21
|
+
import { Plugin, PluginKey } from 'https://esm.sh/@tiptap/pm@2.2.4/state';
|
|
22
|
+
import { Decoration, DecorationSet } from 'https://esm.sh/@tiptap/pm@2.2.4/view';
|
|
23
|
+
|
|
24
|
+
import { escapeHtml } from './wen-utils.js';
|
|
25
|
+
|
|
26
|
+
const lowlight = createLowlight(common);
|
|
27
|
+
|
|
28
|
+
const GitHubAlerts = Extension.create({
|
|
29
|
+
name: 'githubAlerts',
|
|
30
|
+
addProseMirrorPlugins() {
|
|
31
|
+
return [
|
|
32
|
+
new Plugin({
|
|
33
|
+
key: new PluginKey('githubAlerts'),
|
|
34
|
+
props: {
|
|
35
|
+
decorations(state) {
|
|
36
|
+
const decorations = [];
|
|
37
|
+
const { doc } = state;
|
|
38
|
+
|
|
39
|
+
doc.descendants((node, pos) => {
|
|
40
|
+
if (node.type.name === 'blockquote') {
|
|
41
|
+
const firstChild = node.firstChild;
|
|
42
|
+
|
|
43
|
+
// Check if the blockquote starts with a paragraph
|
|
44
|
+
if (firstChild && firstChild.type.name === 'paragraph') {
|
|
45
|
+
const text = firstChild.textContent;
|
|
46
|
+
const match = text.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
|
|
47
|
+
|
|
48
|
+
if (match) {
|
|
49
|
+
const type = match[1].toLowerCase();
|
|
50
|
+
|
|
51
|
+
// 1. Decorate the parent Blockquote wrapper
|
|
52
|
+
decorations.push(
|
|
53
|
+
Decoration.node(pos, pos + node.nodeSize, {
|
|
54
|
+
class: `wen-github-alert wen-github-alert-${type}`
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// 2. Decorate the exact [!TYPE] text to look like a colored badge
|
|
59
|
+
// (pos = blockquote, pos+1 = paragraph, pos+2 = start of text node)
|
|
60
|
+
const startPos = pos + 2;
|
|
61
|
+
const endPos = startPos + match[0].length;
|
|
62
|
+
|
|
63
|
+
decorations.push(
|
|
64
|
+
Decoration.inline(startPos, endPos, {
|
|
65
|
+
class: `wen-github-alert-badge wen-github-alert-badge-${type}`
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return DecorationSet.create(doc, decorations);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const WenSmartBlocks = CodeBlockLowlight.extend({
|
|
81
|
+
addOptions() {
|
|
82
|
+
return { ...this.parent?.(), blockViews: {}, lowlight };
|
|
83
|
+
},
|
|
84
|
+
addNodeView() {
|
|
85
|
+
return ({ node, getPos, editor }) => {
|
|
86
|
+
const lang = node.attrs.language;
|
|
87
|
+
const ViewClass = this.options.blockViews[lang];
|
|
88
|
+
if (ViewClass) return new ViewClass(node, getPos, editor);
|
|
89
|
+
|
|
90
|
+
const dom = document.createElement('pre');
|
|
91
|
+
const contentDOM = document.createElement('code');
|
|
92
|
+
if (lang) contentDOM.className = `language-${lang}`;
|
|
93
|
+
dom.appendChild(contentDOM);
|
|
94
|
+
return { dom, contentDOM };
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export class WenEditor {
|
|
100
|
+
constructor(options) {
|
|
101
|
+
this.container = options.element;
|
|
102
|
+
this.blockViews = options.blockViews || {};
|
|
103
|
+
this.useFrontmatter = options.useFrontmatter !== false;
|
|
104
|
+
this.useNativeComponents = options.useNativeComponents !== false;
|
|
105
|
+
// Safe by default: rich-render paths are sanitized and mermaid runs in 'strict' mode.
|
|
106
|
+
// Pass `unsafe: true` to opt out and render untrusted HTML/diagrams verbatim.
|
|
107
|
+
this.unsafe = options.unsafe === true;
|
|
108
|
+
|
|
109
|
+
this.wrapper = document.createElement('div');
|
|
110
|
+
this.wrapper.className = 'wen-editor-wrapper';
|
|
111
|
+
|
|
112
|
+
this.toolbar = document.createElement('div');
|
|
113
|
+
this.toolbar.className = 'wen-toolbar';
|
|
114
|
+
|
|
115
|
+
this.editorNode = document.createElement('div');
|
|
116
|
+
|
|
117
|
+
this.wrapper.appendChild(this.toolbar);
|
|
118
|
+
this.wrapper.appendChild(this.editorNode);
|
|
119
|
+
this.container.appendChild(this.wrapper);
|
|
120
|
+
|
|
121
|
+
const processedMarkdown = this.importMarkdown(options.initialMarkdown || '');
|
|
122
|
+
|
|
123
|
+
this.editor = new Editor({
|
|
124
|
+
element: this.editorNode,
|
|
125
|
+
extensions: [
|
|
126
|
+
StarterKit.configure({ codeBlock: false }),
|
|
127
|
+
WenSmartBlocks.configure({ blockViews: this.blockViews }),
|
|
128
|
+
GitHubAlerts,
|
|
129
|
+
Markdown,
|
|
130
|
+
Image,
|
|
131
|
+
Link.configure({ openOnClick: false }),
|
|
132
|
+
Table.configure({ resizable: true, HTMLAttributes: { class: 'wen-table' } }),
|
|
133
|
+
TableRow,
|
|
134
|
+
TableHeader,
|
|
135
|
+
TableCell,
|
|
136
|
+
TaskList,
|
|
137
|
+
TaskItem.configure({ nested: true }),
|
|
138
|
+
],
|
|
139
|
+
content: processedMarkdown,
|
|
140
|
+
editorProps: { attributes: { class: 'wen-editor-root' } },
|
|
141
|
+
onTransaction: () => this.updateToolbarStates()
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
this.editor.wenEditorInstance = this;
|
|
145
|
+
|
|
146
|
+
this.buildToolbar();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
importMarkdown(md) {
|
|
150
|
+
let processed = md;
|
|
151
|
+
if (this.useFrontmatter) {
|
|
152
|
+
processed = processed.replace(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/, '```yaml\n$1\n```\n\n');
|
|
153
|
+
}
|
|
154
|
+
if (this.useNativeComponents) {
|
|
155
|
+
const tokens = processed.split(/(^```[\s\S]*?^```\r?\n?)/m);
|
|
156
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
157
|
+
if (i % 2 === 0) {
|
|
158
|
+
let chunk = tokens[i];
|
|
159
|
+
|
|
160
|
+
// THE FIX: Prepend a newline to ensure TipTap never merges adjacent components into a single broken code block
|
|
161
|
+
const openCloseRegex = /^[ \t]*<([a-zA-Z0-9-]+)[^>]*>[\s\S]*?<\/\1>[ \t]*/gm;
|
|
162
|
+
chunk = chunk.replace(openCloseRegex, (match) => '\n```component\n' + match.trim() + '\n```\n');
|
|
163
|
+
|
|
164
|
+
const selfCloseRegex = /^[ \t]*<([a-zA-Z0-9-]+)[^>]*\/>[ \t]*/gm;
|
|
165
|
+
chunk = chunk.replace(selfCloseRegex, (match) => '\n```component\n' + match.trim() + '\n```\n');
|
|
166
|
+
|
|
167
|
+
tokens[i] = chunk;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
processed = tokens.join('');
|
|
171
|
+
}
|
|
172
|
+
return processed;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
loadContent(md) {
|
|
176
|
+
// Run the text through our pre-processor to handle YAML and Components
|
|
177
|
+
const processed = this.importMarkdown(md);
|
|
178
|
+
// Replace the entire document safely
|
|
179
|
+
this.editor.commands.setContent(processed);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
exportMarkdown() {
|
|
183
|
+
let md = this.editor.storage.markdown.getMarkdown();
|
|
184
|
+
if (this.useFrontmatter) {
|
|
185
|
+
md = md.replace(/^```yaml\r?\n([\s\S]*?)\r?\n```\r?\n?/, '---\n$1\n---\n');
|
|
186
|
+
}
|
|
187
|
+
if (this.useNativeComponents) {
|
|
188
|
+
// THE FIX: Allow leading [ \t]* and trailing whitespace inside the block wrapper
|
|
189
|
+
const unwrapRegex = /```component\r?\n([ \t]*<(?:[a-zA-Z0-9-]+)[\s\S]*?>)[ \t]*\r?\n```\r?\n?/gm;
|
|
190
|
+
md = md.replace(unwrapRegex, '$1\n');
|
|
191
|
+
}
|
|
192
|
+
return md;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
downloadMarkdown(filename = 'wen-document.md') {
|
|
196
|
+
const md = this.exportMarkdown();
|
|
197
|
+
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
|
|
198
|
+
const url = URL.createObjectURL(blob);
|
|
199
|
+
|
|
200
|
+
const link = document.createElement('a');
|
|
201
|
+
link.href = url;
|
|
202
|
+
link.download = filename;
|
|
203
|
+
|
|
204
|
+
// Append to body, click it, and instantly clean it up
|
|
205
|
+
document.body.appendChild(link);
|
|
206
|
+
link.click();
|
|
207
|
+
document.body.removeChild(link);
|
|
208
|
+
URL.revokeObjectURL(url);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
buildToolbar() {
|
|
212
|
+
// 3. Upgraded createBtn to support disabled states
|
|
213
|
+
const createBtn = (label, action, isActiveCheck = null, isDisabledCheck = null) => {
|
|
214
|
+
const btn = document.createElement('button');
|
|
215
|
+
btn.className = 'wen-toolbar-btn';
|
|
216
|
+
btn.innerHTML = label;
|
|
217
|
+
btn.addEventListener('click', () => { action(); this.editor.view.focus(); });
|
|
218
|
+
if (isActiveCheck) btn.isActiveCheck = isActiveCheck;
|
|
219
|
+
if (isDisabledCheck) btn.isDisabledCheck = isDisabledCheck;
|
|
220
|
+
this.toolbar.appendChild(btn);
|
|
221
|
+
return btn;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const addDivider = () => {
|
|
225
|
+
const div = document.createElement('div');
|
|
226
|
+
div.className = 'wen-toolbar-divider';
|
|
227
|
+
this.toolbar.appendChild(div);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
createBtn('<b>B</b>', () => this.editor.chain().focus().toggleBold().run(), () => this.editor.isActive('bold'));
|
|
231
|
+
createBtn('<i>I</i>', () => this.editor.chain().focus().toggleItalic().run(), () => this.editor.isActive('italic'));
|
|
232
|
+
createBtn('<s>S</s>', () => this.editor.chain().focus().toggleStrike().run(), () => this.editor.isActive('strike'));
|
|
233
|
+
addDivider();
|
|
234
|
+
createBtn('¶ Text', () => this.editor.chain().focus().setParagraph().run(), () => this.editor.isActive('paragraph'));
|
|
235
|
+
createBtn('H1', () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(), () => this.editor.isActive('heading', { level: 1 }));
|
|
236
|
+
createBtn('H2', () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(), () => this.editor.isActive('heading', { level: 2 }));
|
|
237
|
+
createBtn('H3', () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(), () => this.editor.isActive('heading', { level: 3 }));
|
|
238
|
+
createBtn('H4', () => this.editor.chain().focus().toggleHeading({ level: 4 }).run(), () => this.editor.isActive('heading', { level: 4 }));
|
|
239
|
+
addDivider();
|
|
240
|
+
createBtn('• List', () => this.editor.chain().focus().toggleBulletList().run(), () => this.editor.isActive('bulletList'));
|
|
241
|
+
createBtn('1. List', () => this.editor.chain().focus().toggleOrderedList().run(), () => this.editor.isActive('orderedList'));
|
|
242
|
+
createBtn('☑️ Task', () => this.editor.chain().focus().toggleTaskList().run(), () => this.editor.isActive('taskList'));
|
|
243
|
+
createBtn('\" Quote', () => this.editor.chain().focus().toggleBlockquote().run(), () => this.editor.isActive('blockquote'));
|
|
244
|
+
createBtn('📢 Alert', () => {
|
|
245
|
+
this.editor.chain().focus().insertContent('<blockquote><p>[!NOTE]<br>Your message here...</p></blockquote>').run();
|
|
246
|
+
});
|
|
247
|
+
addDivider();
|
|
248
|
+
|
|
249
|
+
createBtn('🔗 Link', () => {
|
|
250
|
+
const previousUrl = this.editor.getAttributes('link').href;
|
|
251
|
+
this.showModal('Insert Link', 'URL', previousUrl, (url) => {
|
|
252
|
+
if (url === null) return;
|
|
253
|
+
if (url === '') this.editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
254
|
+
else this.editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
255
|
+
});
|
|
256
|
+
}, () => this.editor.isActive('link'));
|
|
257
|
+
|
|
258
|
+
createBtn('🖼️ Image', () => {
|
|
259
|
+
const attrs = this.editor.getAttributes('image');
|
|
260
|
+
const previousUrl = attrs.src || '';
|
|
261
|
+
|
|
262
|
+
this.showModal(previousUrl ? 'Edit Image' : 'Insert Image', 'Image URL', previousUrl, (url) => {
|
|
263
|
+
if (url) {
|
|
264
|
+
this.editor.chain().focus().setImage({ src: url }).run();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}, () => this.editor.isActive('image'));
|
|
268
|
+
|
|
269
|
+
addDivider();
|
|
270
|
+
|
|
271
|
+
// 4. The Table Controls
|
|
272
|
+
createBtn('📊 Table', () => this.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run());
|
|
273
|
+
createBtn('+ Row', () => this.editor.chain().focus().addRowAfter().run(), null, () => !this.editor.can().addRowAfter());
|
|
274
|
+
createBtn('+ Col', () => this.editor.chain().focus().addColumnAfter().run(), null, () => !this.editor.can().addColumnAfter());
|
|
275
|
+
createBtn('- Row', () => this.editor.chain().focus().deleteRow().run(), null, () => !this.editor.can().deleteRow());
|
|
276
|
+
createBtn('- Col', () => this.editor.chain().focus().deleteColumn().run(), null, () => !this.editor.can().deleteColumn());
|
|
277
|
+
createBtn('x Table', () => this.editor.chain().focus().deleteTable().run(), null, () => !this.editor.can().deleteTable());
|
|
278
|
+
|
|
279
|
+
// THE NEW ADDITION: The Save Button
|
|
280
|
+
addDivider();
|
|
281
|
+
createBtn('💾 Save', () => {
|
|
282
|
+
// You could easily hook up a prompt here to ask for a filename if you wanted!
|
|
283
|
+
this.downloadMarkdown('wen-document.md');
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
updateToolbarStates() {
|
|
288
|
+
this.toolbar.querySelectorAll('.wen-toolbar-btn').forEach(btn => {
|
|
289
|
+
if (btn.isActiveCheck) {
|
|
290
|
+
if (btn.isActiveCheck()) btn.classList.add('is-active');
|
|
291
|
+
else btn.classList.remove('is-active');
|
|
292
|
+
}
|
|
293
|
+
// Check if the button should be disabled (e.g. table actions when not in a table)
|
|
294
|
+
if (btn.isDisabledCheck) {
|
|
295
|
+
btn.disabled = btn.isDisabledCheck();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
showModal(title, inputPlaceholder, initialValue, callback) {
|
|
301
|
+
const overlay = document.createElement('div');
|
|
302
|
+
overlay.className = 'wen-modal-overlay';
|
|
303
|
+
overlay.innerHTML = `
|
|
304
|
+
<div class="wen-modal">
|
|
305
|
+
<strong>${escapeHtml(title)}</strong>
|
|
306
|
+
<input type="text" placeholder="${escapeHtml(inputPlaceholder)}" value="${escapeHtml(initialValue || '')}" />
|
|
307
|
+
<div class="wen-modal-actions">
|
|
308
|
+
<button class="wen-modal-cancel">Cancel</button>
|
|
309
|
+
<button class="wen-modal-submit">Save</button>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
`;
|
|
313
|
+
document.body.appendChild(overlay);
|
|
314
|
+
const input = overlay.querySelector('input');
|
|
315
|
+
input.focus();
|
|
316
|
+
|
|
317
|
+
const close = (value) => { document.body.removeChild(overlay); callback(value); };
|
|
318
|
+
overlay.querySelector('.wen-modal-cancel').addEventListener('click', () => close(null));
|
|
319
|
+
overlay.querySelector('.wen-modal-submit').addEventListener('click', () => close(input.value));
|
|
320
|
+
input.addEventListener('keydown', (e) => {
|
|
321
|
+
if (e.key === 'Enter') close(input.value);
|
|
322
|
+
if (e.key === 'Escape') close(null);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
showRichModal(title, initialMarkdown, callback) {
|
|
327
|
+
const overlay = document.createElement('div');
|
|
328
|
+
overlay.className = 'wen-rich-modal-overlay';
|
|
329
|
+
overlay.innerHTML = `
|
|
330
|
+
<div class="wen-rich-modal">
|
|
331
|
+
<div class="wen-rich-modal-header">
|
|
332
|
+
<strong>Editing Content: <${escapeHtml(title)}></strong>
|
|
333
|
+
<div>
|
|
334
|
+
<button class="wen-modal-cancel">Cancel</button>
|
|
335
|
+
<button class="wen-modal-submit">Save Changes</button>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
<div class="wen-rich-modal-body" id="modal-editor-container"></div>
|
|
339
|
+
</div>
|
|
340
|
+
`;
|
|
341
|
+
document.body.appendChild(overlay);
|
|
342
|
+
|
|
343
|
+
// Spawn a recursive child WenEditor inside the modal!
|
|
344
|
+
const modalEditor = new WenEditor({
|
|
345
|
+
element: overlay.querySelector('#modal-editor-container'),
|
|
346
|
+
blockViews: this.blockViews, // Inherit all our rich UI blocks
|
|
347
|
+
useFrontmatter: false, // Sub-components don't need YAML frontmatter
|
|
348
|
+
useNativeComponents: this.useNativeComponents,
|
|
349
|
+
unsafe: this.unsafe, // Inherit the trust setting into recursive editors
|
|
350
|
+
initialMarkdown: initialMarkdown
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const close = (save) => {
|
|
354
|
+
const finalMd = save ? modalEditor.exportMarkdown() : null;
|
|
355
|
+
modalEditor.editor.destroy(); // Clean up memory
|
|
356
|
+
document.body.removeChild(overlay);
|
|
357
|
+
if (save) callback(finalMd);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
overlay.querySelector('.wen-modal-cancel').addEventListener('click', () => close(false));
|
|
361
|
+
overlay.querySelector('.wen-modal-submit').addEventListener('click', () => close(true));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
.wen-mermaid-block {
|
|
2
|
+
border: 2px solid #0d9488;
|
|
3
|
+
border-radius: var(--wen-radius);
|
|
4
|
+
margin: 1.5rem 0;
|
|
5
|
+
background: var(--wen-bg);
|
|
6
|
+
box-sizing: border-box;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.wen-mermaid-block *, .wen-mermaid-block *::before, .wen-mermaid-block *::after {
|
|
10
|
+
box-sizing: border-box;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.wen-mermaid-header {
|
|
14
|
+
display: flex;
|
|
15
|
+
justify-content: space-between;
|
|
16
|
+
align-items: center;
|
|
17
|
+
border-bottom: 2px solid #0d9488;
|
|
18
|
+
padding: 0.5rem 1rem;
|
|
19
|
+
background: #0d9488;
|
|
20
|
+
color: white;
|
|
21
|
+
border-top-left-radius: calc(var(--wen-radius) - 2px);
|
|
22
|
+
border-top-right-radius: calc(var(--wen-radius) - 2px);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.wen-mermaid-title { font-size: 0.85rem; font-weight: bold; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
26
|
+
.wen-mermaid-toggle-btn { background: var(--wen-bg); border: none; color: var(--wen-text-main); cursor: pointer; font-size: 0.75rem; padding: 0.25rem 0.6rem; border-radius: var(--wen-radius-sm); font-weight: 600; transition: background 0.15s; }
|
|
27
|
+
.wen-mermaid-toggle-btn:hover { background: var(--wen-surface-hover); }
|
|
28
|
+
|
|
29
|
+
/* THE FIX: Hard-lock the display states so they cannot both show at once */
|
|
30
|
+
.wen-mermaid-view-visual, .wen-mermaid-view-raw {
|
|
31
|
+
display: none !important;
|
|
32
|
+
padding: 1rem;
|
|
33
|
+
}
|
|
34
|
+
.wen-mermaid-view-visual.active, .wen-mermaid-view-raw.active {
|
|
35
|
+
display: block !important;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Ensure the SVG scales nicely and forces dark text */
|
|
39
|
+
.wen-mermaid-view-visual {
|
|
40
|
+
text-align: center;
|
|
41
|
+
overflow-x: auto;
|
|
42
|
+
background: var(--wen-surface);
|
|
43
|
+
border-bottom-left-radius: var(--wen-radius);
|
|
44
|
+
border-bottom-right-radius: var(--wen-radius);
|
|
45
|
+
color: #1f2937 !important;
|
|
46
|
+
}
|
|
47
|
+
.wen-mermaid-view-visual svg { max-width: 100%; height: auto !important; color: #1f2937; }
|
|
48
|
+
|
|
49
|
+
.wen-mermaid-raw-input { width: 100%; min-height: 250px; border: 1px solid var(--wen-border); border-radius: var(--wen-radius-sm); padding: 0.75rem; font-family: var(--wen-font-mono); font-size: 0.9rem; background: #1e1e1e; color: #d4d4d4; resize: vertical; outline: none; }
|
|
50
|
+
.wen-mermaid-error { color: var(--wen-danger); font-family: var(--wen-font-mono); font-size: 0.85rem; text-align: left; background: #fef2f2; padding: 1rem; border-radius: var(--wen-radius-sm); border-left: 4px solid var(--wen-danger); white-space: pre-wrap; }
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import mermaid from 'https://esm.sh/mermaid@10.9.1';
|
|
2
|
+
import { escapeHtml } from './wen-utils.js';
|
|
3
|
+
|
|
4
|
+
// Initialize Mermaid with a neutral theme that works well inside the editor.
|
|
5
|
+
// Default to 'strict' (no inline HTML / click handlers); re-initialized per render
|
|
6
|
+
// to 'loose' only when the editor is in unsafe mode.
|
|
7
|
+
mermaid.initialize({
|
|
8
|
+
startOnLoad: false,
|
|
9
|
+
theme: 'neutral',
|
|
10
|
+
securityLevel: 'strict'
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export class WenMermaidView {
|
|
14
|
+
constructor(node, getPos, editor) {
|
|
15
|
+
this.node = node;
|
|
16
|
+
this.getPos = getPos;
|
|
17
|
+
this.editor = editor;
|
|
18
|
+
// Mermaid requires a strictly unique ID for every diagram it renders
|
|
19
|
+
this.id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
|
|
20
|
+
|
|
21
|
+
this.dom = document.createElement('div');
|
|
22
|
+
this.dom.className = 'wen-mermaid-block';
|
|
23
|
+
this.dom.contentEditable = 'false';
|
|
24
|
+
|
|
25
|
+
this.dom.innerHTML = `
|
|
26
|
+
<div class="wen-mermaid-header">
|
|
27
|
+
<span class="wen-mermaid-title">🧜♀️ Mermaid Diagram</span>
|
|
28
|
+
<button class="wen-mermaid-toggle-btn" data-state="visual">Edit Chart</button>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="wen-mermaid-view-visual active"></div>
|
|
31
|
+
<div class="wen-mermaid-view-raw"><textarea class="wen-mermaid-raw-input"></textarea></div>
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const toggleBtn = this.dom.querySelector('.wen-mermaid-toggle-btn');
|
|
35
|
+
const visualView = this.dom.querySelector('.wen-mermaid-view-visual');
|
|
36
|
+
const rawView = this.dom.querySelector('.wen-mermaid-view-raw');
|
|
37
|
+
const rawInput = this.dom.querySelector('.wen-mermaid-raw-input');
|
|
38
|
+
|
|
39
|
+
rawInput.value = this.node.textContent;
|
|
40
|
+
let isUpdatingFromUI = false;
|
|
41
|
+
|
|
42
|
+
const updateTipTapState = (newRawContent) => {
|
|
43
|
+
if (newRawContent === this.node.textContent) return;
|
|
44
|
+
if (typeof this.getPos === 'function') {
|
|
45
|
+
const tr = this.editor.state.tr;
|
|
46
|
+
const start = this.getPos() + 1;
|
|
47
|
+
const end = start + this.node.nodeSize - 2;
|
|
48
|
+
if (newRawContent) tr.replaceWith(start, end, this.editor.schema.text(newRawContent));
|
|
49
|
+
else tr.delete(start, end);
|
|
50
|
+
this.editor.view.dispatch(tr);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const renderVisualDiagram = async () => {
|
|
55
|
+
visualView.innerHTML = '';
|
|
56
|
+
const code = rawInput.value.trim();
|
|
57
|
+
|
|
58
|
+
if (!code) {
|
|
59
|
+
visualView.innerHTML = `<div class="wen-mermaid-empty">Empty diagram</div>`;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// Respect the editor's trust setting: only loosen mermaid for unsafe mode.
|
|
65
|
+
const unsafe = this.editor.wenEditorInstance?.unsafe === true;
|
|
66
|
+
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: unsafe ? 'loose' : 'strict' });
|
|
67
|
+
|
|
68
|
+
// Parse and render the SVG asynchronously
|
|
69
|
+
const { svg } = await mermaid.render(this.id, code);
|
|
70
|
+
visualView.innerHTML = svg;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
// If the syntax is invalid, show a safe error message instead of crashing
|
|
73
|
+
visualView.innerHTML = `<div class="wen-mermaid-error"><strong>Syntax Error:</strong><br/>${escapeHtml(err.message)}</div>`;
|
|
74
|
+
|
|
75
|
+
// Mermaid occasionally leaves garbage DOM nodes behind on failure, clean them up
|
|
76
|
+
const garbage = document.getElementById(this.id);
|
|
77
|
+
if (garbage) garbage.remove();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
toggleBtn.addEventListener('click', () => {
|
|
82
|
+
const isVisual = toggleBtn.getAttribute('data-state') === 'visual';
|
|
83
|
+
if (isVisual) {
|
|
84
|
+
visualView.classList.remove('active');
|
|
85
|
+
rawView.classList.add('active');
|
|
86
|
+
toggleBtn.setAttribute('data-state', 'raw');
|
|
87
|
+
toggleBtn.innerText = 'View Chart';
|
|
88
|
+
} else {
|
|
89
|
+
renderVisualDiagram();
|
|
90
|
+
rawView.classList.remove('active');
|
|
91
|
+
visualView.classList.add('active');
|
|
92
|
+
toggleBtn.setAttribute('data-state', 'visual');
|
|
93
|
+
toggleBtn.innerText = 'Edit Chart';
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// When the raw textarea changes, update TipTap
|
|
98
|
+
rawInput.addEventListener('input', () => {
|
|
99
|
+
isUpdatingFromUI = true;
|
|
100
|
+
updateTipTapState(rawInput.value);
|
|
101
|
+
isUpdatingFromUI = false;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Initial Render
|
|
105
|
+
renderVisualDiagram();
|
|
106
|
+
|
|
107
|
+
// The TipTap Sync Hook
|
|
108
|
+
this.update = (updatedNode) => {
|
|
109
|
+
if (updatedNode.type.name !== 'codeBlock' || updatedNode.attrs.language !== 'mermaid') return false;
|
|
110
|
+
|
|
111
|
+
if (this.node.textContent !== updatedNode.textContent) {
|
|
112
|
+
this.node = updatedNode;
|
|
113
|
+
rawInput.value = this.node.textContent;
|
|
114
|
+
|
|
115
|
+
// If Monaco (or another external source) changed the text, re-render the visual
|
|
116
|
+
if (!isUpdatingFromUI) {
|
|
117
|
+
renderVisualDiagram();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
this.stopEvent = () => true;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/wen-utils.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Shared internal helpers for the ƿen editor.
|
|
2
|
+
// Imported by the view classes; not part of the public API surface.
|
|
3
|
+
|
|
4
|
+
import DOMPurify from 'https://esm.sh/dompurify@3.1.6';
|
|
5
|
+
|
|
6
|
+
// Escape a string for safe interpolation into HTML text or double-quoted attributes.
|
|
7
|
+
// This is baseline correctness (a stray `"` or `<` in a value must not break the DOM)
|
|
8
|
+
// and is applied regardless of `unsafe` mode.
|
|
9
|
+
export function escapeHtml(value) {
|
|
10
|
+
return String(value ?? '')
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Sanitize a rich-HTML fragment (e.g. marked output) before it is injected via innerHTML.
|
|
19
|
+
// Only called on the safe-mode render paths; `unsafe: true` bypasses this entirely.
|
|
20
|
+
export function sanitizeHtml(dirty) {
|
|
21
|
+
return DOMPurify.sanitize(dirty);
|
|
22
|
+
}
|
package/src/wen-yaml.css
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/* Prevent width + padding horizontal blowouts */
|
|
2
|
+
.wen-yaml-block *, .wen-yaml-block *::before, .wen-yaml-block *::after {
|
|
3
|
+
box-sizing: border-box;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.wen-yaml-block { border: 1px solid var(--wen-border); border-radius: var(--wen-radius); margin: 1.5rem 0; background: var(--wen-bg); }
|
|
7
|
+
.wen-yaml-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--wen-border); padding: 0.5rem 1rem; background: var(--wen-surface); border-top-left-radius: calc(var(--wen-radius) - 1px); border-top-right-radius: calc(var(--wen-radius) - 1px); }
|
|
8
|
+
.wen-yaml-title { font-size: 0.85rem; font-weight: 600; color: var(--wen-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
9
|
+
.wen-yaml-toggle-btn { background: var(--wen-bg); border: 1px solid var(--wen-border); color: var(--wen-text-muted); cursor: pointer; font-size: 0.75rem; padding: 0.25rem 0.6rem; border-radius: var(--wen-radius-sm); }
|
|
10
|
+
.wen-yaml-toggle-btn:hover { background: var(--wen-surface-hover); color: var(--wen-text-main); }
|
|
11
|
+
|
|
12
|
+
/* Collapsible Container Logic */
|
|
13
|
+
.wen-yaml-visual-container { position: relative; display: block; }
|
|
14
|
+
.wen-yaml-view-visual { padding: 0.5rem !important; }
|
|
15
|
+
.wen-yaml-view-raw, .wen-yaml-error-state { display: none; padding: 1rem; }
|
|
16
|
+
.wen-yaml-view-raw.active, .wen-yaml-error-state.active { display: block; }
|
|
17
|
+
.wen-yaml-visual-container.is-collapsed .wen-yaml-view-visual { max-height: 220px; overflow: hidden; }
|
|
18
|
+
.wen-yaml-fade { display: none; }
|
|
19
|
+
.wen-yaml-visual-container.is-collapsed .wen-yaml-fade { display: block; position: absolute; bottom: 33px; left: 0; right: 0; height: 60px; background: linear-gradient(transparent, var(--wen-bg)); pointer-events: none; z-index: 5; }
|
|
20
|
+
.wen-yaml-expand-btn { display: none; width: 100%; background: var(--wen-surface); border: none; border-top: 1px solid var(--wen-border); padding: 0.5rem; color: var(--wen-text-muted); cursor: pointer; font-size: 0.8rem; text-align: center; font-weight: 600; transition: background 0.15s; border-bottom-left-radius: calc(var(--wen-radius) - 1px); border-bottom-right-radius: calc(var(--wen-radius) - 1px); }
|
|
21
|
+
.wen-yaml-expand-btn:hover { color: var(--wen-text-main); background: var(--wen-border); }
|
|
22
|
+
.wen-yaml-visual-container.needs-collapse .wen-yaml-expand-btn { display: block; }
|
|
23
|
+
|
|
24
|
+
.wen-yaml-raw-input { width: 100%; min-height: 150px; border: 1px solid var(--wen-border); border-radius: var(--wen-radius-sm); padding: 0.75rem; font-family: var(--wen-font-mono); font-size: 0.9rem; color: var(--wen-text-main); background: var(--wen-surface); resize: vertical; outline: none; }
|
|
25
|
+
.wen-yaml-error-state { color: var(--wen-danger); background: #fef2f2; border-top: 1px solid #fecaca; font-size: 0.9rem; }
|
|
26
|
+
|
|
27
|
+
/* Hierarchical Vertical Layout */
|
|
28
|
+
.wen-type-object, .wen-type-array { display: flex; flex-direction: column; width: 100%; background: rgba(0, 0, 0, 0.02); border-radius: var(--wen-radius-sm); padding: 0.5rem; border: 1px solid rgba(0,0,0,0.05); }
|
|
29
|
+
.wen-row, .wen-array-item { display: flex; flex-direction: column; margin-bottom: 0.5rem; transition: background 0.15s ease; border-radius: var(--wen-radius-sm); }
|
|
30
|
+
.wen-row-top { display: flex; align-items: flex-start; width: 100%; padding: 0.2rem; }
|
|
31
|
+
.wen-row:hover > .wen-row-top, .wen-array-item:hover > .wen-row-top { background: var(--wen-surface); border-radius: var(--wen-radius-sm); }
|
|
32
|
+
|
|
33
|
+
/* Precise Typography Alignment */
|
|
34
|
+
.wen-key-wrap {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: baseline; /* Aligns the input text and colon perfectly on the bottom edge */
|
|
37
|
+
width: 140px;
|
|
38
|
+
flex-shrink: 0;
|
|
39
|
+
padding: 0; /* Strip any old wrap padding */
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Force identical box models for inputs and textareas */
|
|
43
|
+
.wen-key, .wen-val {
|
|
44
|
+
padding: 0;
|
|
45
|
+
margin: 0;
|
|
46
|
+
font-size: 0.9rem;
|
|
47
|
+
font-family: var(--wen-font-sans);
|
|
48
|
+
line-height: 1.5;
|
|
49
|
+
border: none;
|
|
50
|
+
background: transparent;
|
|
51
|
+
outline: none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.wen-key {
|
|
55
|
+
width: 100%;
|
|
56
|
+
font-weight: 600;
|
|
57
|
+
color: var(--wen-text-muted);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.wen-val {
|
|
61
|
+
flex-grow: 1;
|
|
62
|
+
color: var(--wen-text-main);
|
|
63
|
+
resize: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.wen-colon {
|
|
67
|
+
margin: 0 0.5rem 0 0;
|
|
68
|
+
padding: 0;
|
|
69
|
+
color: var(--wen-text-muted);
|
|
70
|
+
font-weight: bold;
|
|
71
|
+
font-size: 0.9rem;
|
|
72
|
+
line-height: 1.5;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* Italic Placeholders */
|
|
76
|
+
.wen-key::placeholder,
|
|
77
|
+
.wen-val::placeholder {
|
|
78
|
+
font-style: italic;
|
|
79
|
+
opacity: 0.6;
|
|
80
|
+
font-weight: normal;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.wen-key:focus { color: var(--wen-text-main); }
|
|
84
|
+
|
|
85
|
+
/* Primitives sit on the same line, complex drops below */
|
|
86
|
+
.wen-val-wrap { flex-grow: 1; min-width: 0; display: flex; align-items: flex-start; }
|
|
87
|
+
.wen-complex-wrap { padding-left: 1rem; width: 100%; margin-top: 0.25rem; }
|
|
88
|
+
.wen-type-primitive { display: flex; align-items: flex-start; gap: 0.5rem; width: 100%; }
|
|
89
|
+
|
|
90
|
+
/* The Action Buttons */
|
|
91
|
+
.wen-type-controls { display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.2s; }
|
|
92
|
+
.wen-row:hover .wen-type-controls, .wen-array-item:hover .wen-type-controls { opacity: 1; }
|
|
93
|
+
.wen-type-controls button { background: var(--wen-bg); border: 1px solid var(--wen-border); border-radius: var(--wen-radius-sm); cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.8rem; color: var(--wen-text-muted); }
|
|
94
|
+
.wen-type-controls button:hover { background: var(--wen-surface-hover); color: var(--wen-text-main); }
|
|
95
|
+
|
|
96
|
+
.wen-bullet { color: var(--wen-text-muted); font-weight: bold; padding-top: 0.2rem; width: 24px; text-align: center; }
|
|
97
|
+
|
|
98
|
+
/* Refined Add/Delete UI */
|
|
99
|
+
.wen-btn-delete { background: transparent; border: none; color: var(--wen-text-muted); font-size: 1.25rem; cursor: pointer; opacity: 0; padding: 0 0.5rem; margin-left: auto; }
|
|
100
|
+
.wen-row:hover > .wen-row-top > .wen-btn-delete, .wen-array-item:hover > .wen-row-top > .wen-btn-delete { opacity: 1; }
|
|
101
|
+
.wen-btn-delete:hover { color: var(--wen-danger); }
|
|
102
|
+
|
|
103
|
+
.wen-btn-add { background: transparent; border: 1px dashed var(--wen-border); color: var(--wen-text-muted); cursor: pointer; text-align: left; font-weight: 500; padding: 0.4rem 0.75rem; font-family: var(--wen-font-sans); transition: all 0.15s ease; border-radius: var(--wen-radius-sm); font-size: 0.85rem; margin-top: 0.25rem; width: 100%; }
|
|
104
|
+
.wen-btn-add:hover { color: var(--wen-text-main); background: var(--wen-surface); border-color: #cbd5e1; }
|