inkpen 0.7.1

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 (95) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.rubocop.yml +8 -0
  4. data/.yardopts +11 -0
  5. data/CLAUDE.md +141 -0
  6. data/README.md +409 -0
  7. data/Rakefile +19 -0
  8. data/app/assets/javascripts/inkpen/controllers/editor_controller.js +2050 -0
  9. data/app/assets/javascripts/inkpen/controllers/sticky_toolbar_controller.js +667 -0
  10. data/app/assets/javascripts/inkpen/controllers/toolbar_controller.js +693 -0
  11. data/app/assets/javascripts/inkpen/export/html.js +637 -0
  12. data/app/assets/javascripts/inkpen/export/index.js +30 -0
  13. data/app/assets/javascripts/inkpen/export/markdown.js +697 -0
  14. data/app/assets/javascripts/inkpen/export/pdf.js +372 -0
  15. data/app/assets/javascripts/inkpen/extensions/advanced_table.js +640 -0
  16. data/app/assets/javascripts/inkpen/extensions/block_commands.js +300 -0
  17. data/app/assets/javascripts/inkpen/extensions/block_gutter.js +338 -0
  18. data/app/assets/javascripts/inkpen/extensions/callout.js +303 -0
  19. data/app/assets/javascripts/inkpen/extensions/columns.js +403 -0
  20. data/app/assets/javascripts/inkpen/extensions/database.js +990 -0
  21. data/app/assets/javascripts/inkpen/extensions/document_section.js +352 -0
  22. data/app/assets/javascripts/inkpen/extensions/drag_handle.js +407 -0
  23. data/app/assets/javascripts/inkpen/extensions/embed.js +629 -0
  24. data/app/assets/javascripts/inkpen/extensions/enhanced_image.js +566 -0
  25. data/app/assets/javascripts/inkpen/extensions/export_commands.js +271 -0
  26. data/app/assets/javascripts/inkpen/extensions/file_attachment.js +593 -0
  27. data/app/assets/javascripts/inkpen/extensions/inkpen_table/index.js +58 -0
  28. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table.js +638 -0
  29. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_cell.js +100 -0
  30. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_header.js +100 -0
  31. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_constants.js +152 -0
  32. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_helpers.js +254 -0
  33. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_menu.js +282 -0
  34. data/app/assets/javascripts/inkpen/extensions/preformatted.js +239 -0
  35. data/app/assets/javascripts/inkpen/extensions/section.js +281 -0
  36. data/app/assets/javascripts/inkpen/extensions/section_title.js +126 -0
  37. data/app/assets/javascripts/inkpen/extensions/slash_commands.js +439 -0
  38. data/app/assets/javascripts/inkpen/extensions/table_of_contents.js +474 -0
  39. data/app/assets/javascripts/inkpen/extensions/toggle_block.js +332 -0
  40. data/app/assets/javascripts/inkpen/index.js +87 -0
  41. data/app/assets/stylesheets/inkpen/advanced_table.css +514 -0
  42. data/app/assets/stylesheets/inkpen/animations.css +626 -0
  43. data/app/assets/stylesheets/inkpen/block_gutter.css +265 -0
  44. data/app/assets/stylesheets/inkpen/callout.css +359 -0
  45. data/app/assets/stylesheets/inkpen/columns.css +314 -0
  46. data/app/assets/stylesheets/inkpen/database.css +658 -0
  47. data/app/assets/stylesheets/inkpen/document_section.css +305 -0
  48. data/app/assets/stylesheets/inkpen/drag_drop.css +220 -0
  49. data/app/assets/stylesheets/inkpen/editor.css +652 -0
  50. data/app/assets/stylesheets/inkpen/embed.css +468 -0
  51. data/app/assets/stylesheets/inkpen/enhanced_image.css +453 -0
  52. data/app/assets/stylesheets/inkpen/export.css +499 -0
  53. data/app/assets/stylesheets/inkpen/file_attachment.css +347 -0
  54. data/app/assets/stylesheets/inkpen/footnotes.css +136 -0
  55. data/app/assets/stylesheets/inkpen/inkpen_table.css +608 -0
  56. data/app/assets/stylesheets/inkpen/preformatted.css +215 -0
  57. data/app/assets/stylesheets/inkpen/search_replace.css +58 -0
  58. data/app/assets/stylesheets/inkpen/section.css +236 -0
  59. data/app/assets/stylesheets/inkpen/slash_menu.css +252 -0
  60. data/app/assets/stylesheets/inkpen/sticky_toolbar.css +314 -0
  61. data/app/assets/stylesheets/inkpen/toc.css +386 -0
  62. data/app/assets/stylesheets/inkpen/toggle.css +260 -0
  63. data/app/helpers/inkpen/editor_helper.rb +114 -0
  64. data/app/views/inkpen/_editor.html.erb +139 -0
  65. data/config/importmap.rb +170 -0
  66. data/docs/.DS_Store +0 -0
  67. data/docs/CHANGELOG.md +571 -0
  68. data/docs/FEATURES.md +436 -0
  69. data/docs/ROADMAP.md +3029 -0
  70. data/docs/VISION.md +235 -0
  71. data/docs/extensions/INKPEN_TABLE.md +482 -0
  72. data/docs/thinking/CORRECTED_NO_VUE.md +756 -0
  73. data/docs/thinking/EXECUTIVE_SUMMARY.md +403 -0
  74. data/docs/thinking/INKPEN_CODE_SAMPLES.md +1479 -0
  75. data/docs/thinking/INKPEN_MASTER_GUIDE.md +891 -0
  76. data/docs/thinking/README_START_HERE.md +341 -0
  77. data/lib/inkpen/configuration.rb +175 -0
  78. data/lib/inkpen/editor.rb +204 -0
  79. data/lib/inkpen/engine.rb +32 -0
  80. data/lib/inkpen/extensions/base.rb +109 -0
  81. data/lib/inkpen/extensions/code_block_syntax.rb +177 -0
  82. data/lib/inkpen/extensions/document_section.rb +111 -0
  83. data/lib/inkpen/extensions/forced_document.rb +183 -0
  84. data/lib/inkpen/extensions/mention.rb +155 -0
  85. data/lib/inkpen/extensions/preformatted.rb +111 -0
  86. data/lib/inkpen/extensions/section.rb +139 -0
  87. data/lib/inkpen/extensions/slash_commands.rb +100 -0
  88. data/lib/inkpen/extensions/table.rb +182 -0
  89. data/lib/inkpen/extensions/task_list.rb +145 -0
  90. data/lib/inkpen/sticky_toolbar.rb +157 -0
  91. data/lib/inkpen/toolbar.rb +145 -0
  92. data/lib/inkpen/version.rb +5 -0
  93. data/lib/inkpen.rb +101 -0
  94. data/sig/inkpen.rbs +4 -0
  95. metadata +165 -0
@@ -0,0 +1,629 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core"
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state"
3
+
4
+ /**
5
+ * Social Embed Extension for TipTap
6
+ *
7
+ * Embed content from various platforms:
8
+ * - Twitter/X posts
9
+ * - Instagram posts
10
+ * - TikTok videos
11
+ * - Figma designs
12
+ * - Loom videos
13
+ * - CodePen pens
14
+ * - GitHub Gists
15
+ * - Spotify tracks/playlists
16
+ * - Generic link cards for unsupported URLs
17
+ *
18
+ * Privacy-aware: Shows preview until user clicks to load external content.
19
+ *
20
+ * @since 0.5.0
21
+ */
22
+
23
+ const EMBED_KEY = new PluginKey("embed")
24
+
25
+ // Provider configurations
26
+ const PROVIDERS = {
27
+ youtube: {
28
+ name: "YouTube",
29
+ icon: "▶",
30
+ color: "#ff0000",
31
+ patterns: [
32
+ /(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)/,
33
+ /(?:https?:\/\/)?(?:www\.)?youtu\.be\/([a-zA-Z0-9_-]+)/,
34
+ /(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([a-zA-Z0-9_-]+)/
35
+ ],
36
+ getEmbedUrl: (id) => `https://www.youtube.com/embed/${id}`,
37
+ aspectRatio: "16/9"
38
+ },
39
+
40
+ twitter: {
41
+ name: "Twitter",
42
+ icon: "𝕏",
43
+ color: "#000000",
44
+ patterns: [
45
+ /(?:https?:\/\/)?(?:www\.)?(twitter|x)\.com\/(\w+)\/status\/(\d+)/
46
+ ],
47
+ getEmbedUrl: (match) => null, // Uses widget script
48
+ extractId: (match) => ({ user: match[2], id: match[3] }),
49
+ useWidget: true,
50
+ widgetScript: "https://platform.twitter.com/widgets.js"
51
+ },
52
+
53
+ instagram: {
54
+ name: "Instagram",
55
+ icon: "📷",
56
+ color: "#e4405f",
57
+ patterns: [
58
+ /(?:https?:\/\/)?(?:www\.)?instagram\.com\/(p|reel)\/([A-Za-z0-9_-]+)/
59
+ ],
60
+ getEmbedUrl: (id) => null,
61
+ extractId: (match) => ({ type: match[1], id: match[2] }),
62
+ useWidget: true,
63
+ widgetScript: "https://www.instagram.com/embed.js"
64
+ },
65
+
66
+ tiktok: {
67
+ name: "TikTok",
68
+ icon: "♪",
69
+ color: "#000000",
70
+ patterns: [
71
+ /(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@([^\/]+)\/video\/(\d+)/,
72
+ /(?:https?:\/\/)?(?:vm\.)?tiktok\.com\/([A-Za-z0-9]+)/
73
+ ],
74
+ getEmbedUrl: (id) => `https://www.tiktok.com/embed/v2/${id}`,
75
+ aspectRatio: "9/16",
76
+ maxWidth: 325
77
+ },
78
+
79
+ figma: {
80
+ name: "Figma",
81
+ icon: "◈",
82
+ color: "#f24e1e",
83
+ patterns: [
84
+ /(?:https?:\/\/)?(?:www\.)?figma\.com\/(file|proto|design)\/([A-Za-z0-9]+)/
85
+ ],
86
+ getEmbedUrl: (url) => `https://www.figma.com/embed?embed_host=inkpen&url=${encodeURIComponent(url)}`,
87
+ useFullUrl: true,
88
+ aspectRatio: "16/9"
89
+ },
90
+
91
+ loom: {
92
+ name: "Loom",
93
+ icon: "🎥",
94
+ color: "#625df5",
95
+ patterns: [
96
+ /(?:https?:\/\/)?(?:www\.)?loom\.com\/share\/([a-zA-Z0-9]+)/,
97
+ /(?:https?:\/\/)?(?:www\.)?loom\.com\/embed\/([a-zA-Z0-9]+)/
98
+ ],
99
+ getEmbedUrl: (id) => `https://www.loom.com/embed/${id}`,
100
+ aspectRatio: "16/9"
101
+ },
102
+
103
+ codepen: {
104
+ name: "CodePen",
105
+ icon: "⌨",
106
+ color: "#000000",
107
+ patterns: [
108
+ /(?:https?:\/\/)?(?:www\.)?codepen\.io\/([^\/]+)\/pen\/([A-Za-z0-9]+)/,
109
+ /(?:https?:\/\/)?(?:www\.)?codepen\.io\/([^\/]+)\/full\/([A-Za-z0-9]+)/
110
+ ],
111
+ getEmbedUrl: (match) => `https://codepen.io/${match[1]}/embed/${match[2]}?default-tab=result`,
112
+ extractId: (match) => ({ user: match[1], pen: match[2] }),
113
+ aspectRatio: "16/9"
114
+ },
115
+
116
+ gist: {
117
+ name: "GitHub Gist",
118
+ icon: "📋",
119
+ color: "#24292e",
120
+ patterns: [
121
+ /(?:https?:\/\/)?gist\.github\.com\/([^\/]+)\/([a-f0-9]+)/
122
+ ],
123
+ getEmbedUrl: (match) => null,
124
+ extractId: (match) => ({ user: match[1], id: match[2] }),
125
+ useScript: true,
126
+ getScriptUrl: (match) => `https://gist.github.com/${match[1]}/${match[2]}.js`
127
+ },
128
+
129
+ spotify: {
130
+ name: "Spotify",
131
+ icon: "🎵",
132
+ color: "#1db954",
133
+ patterns: [
134
+ /(?:https?:\/\/)?open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/
135
+ ],
136
+ getEmbedUrl: (match) => `https://open.spotify.com/embed/${match[1]}/${match[2]}`,
137
+ extractId: (match) => ({ type: match[1], id: match[2] }),
138
+ height: 352
139
+ },
140
+
141
+ vimeo: {
142
+ name: "Vimeo",
143
+ icon: "▶",
144
+ color: "#1ab7ea",
145
+ patterns: [
146
+ /(?:https?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/,
147
+ /(?:https?:\/\/)?player\.vimeo\.com\/video\/(\d+)/
148
+ ],
149
+ getEmbedUrl: (id) => `https://player.vimeo.com/video/${id}`,
150
+ aspectRatio: "16/9"
151
+ }
152
+ }
153
+
154
+ // Detect provider from URL
155
+ function detectProvider(url) {
156
+ for (const [key, provider] of Object.entries(PROVIDERS)) {
157
+ for (const pattern of provider.patterns) {
158
+ const match = url.match(pattern)
159
+ if (match) {
160
+ return { provider: key, config: provider, match }
161
+ }
162
+ }
163
+ }
164
+ return null
165
+ }
166
+
167
+ // Extract domain from URL
168
+ function extractDomain(url) {
169
+ try {
170
+ const urlObj = new URL(url)
171
+ return urlObj.hostname.replace("www.", "")
172
+ } catch {
173
+ return url
174
+ }
175
+ }
176
+
177
+ export const Embed = Node.create({
178
+ name: "embed",
179
+
180
+ group: "block",
181
+
182
+ atom: true,
183
+
184
+ draggable: true,
185
+
186
+ addOptions() {
187
+ return {
188
+ // Provider settings
189
+ providers: PROVIDERS,
190
+ allowedProviders: null, // null = all, or ["youtube", "twitter"]
191
+ // Privacy mode: show placeholder until clicked
192
+ privacyMode: true,
193
+ // Link card settings for unknown URLs
194
+ enableLinkCards: true,
195
+ linkCardFetcher: null, // Custom function to fetch Open Graph data
196
+ // Callbacks
197
+ onEmbed: null,
198
+ onLoad: null,
199
+ onError: null
200
+ }
201
+ },
202
+
203
+ addAttributes() {
204
+ return {
205
+ url: {
206
+ default: null
207
+ },
208
+ provider: {
209
+ default: null
210
+ },
211
+ embedData: {
212
+ default: null // Provider-specific data (id, user, etc.)
213
+ },
214
+ loaded: {
215
+ default: false // Has the embed been loaded (for privacy mode)
216
+ },
217
+ // Link card data (for generic URLs)
218
+ linkCard: {
219
+ default: null // { title, description, image, domain }
220
+ },
221
+ error: {
222
+ default: null
223
+ }
224
+ }
225
+ },
226
+
227
+ parseHTML() {
228
+ return [
229
+ {
230
+ tag: 'div[data-type="embed"]'
231
+ }
232
+ ]
233
+ },
234
+
235
+ renderHTML({ HTMLAttributes, node }) {
236
+ return [
237
+ "div",
238
+ mergeAttributes(HTMLAttributes, {
239
+ "data-type": "embed",
240
+ "data-provider": node.attrs.provider,
241
+ class: "inkpen-embed"
242
+ }),
243
+ // Content rendered by NodeView
244
+ 0
245
+ ]
246
+ },
247
+
248
+ addNodeView() {
249
+ return ({ node, editor, getPos }) => {
250
+ const extension = this
251
+
252
+ // Create container
253
+ const container = document.createElement("div")
254
+ container.className = "inkpen-embed"
255
+ container.setAttribute("data-type", "embed")
256
+ container.setAttribute("data-provider", node.attrs.provider || "link")
257
+
258
+ const renderEmbed = () => {
259
+ container.innerHTML = ""
260
+
261
+ const provider = node.attrs.provider
262
+ const providerConfig = PROVIDERS[provider]
263
+ const isLoaded = node.attrs.loaded || !extension.options.privacyMode
264
+ const hasError = node.attrs.error
265
+
266
+ if (hasError) {
267
+ // Error state
268
+ container.innerHTML = `
269
+ <div class="inkpen-embed__error">
270
+ <span class="inkpen-embed__error-icon">⚠️</span>
271
+ <span class="inkpen-embed__error-text">${node.attrs.error}</span>
272
+ <button type="button" class="inkpen-embed__remove" title="Remove">×</button>
273
+ </div>
274
+ `
275
+ container.querySelector(".inkpen-embed__remove")?.addEventListener("click", () => {
276
+ if (typeof getPos === "function") {
277
+ editor.chain().focus().deleteRange({
278
+ from: getPos(),
279
+ to: getPos() + node.nodeSize
280
+ }).run()
281
+ }
282
+ })
283
+ return
284
+ }
285
+
286
+ if (!provider || !providerConfig) {
287
+ // Link card for unknown URLs
288
+ renderLinkCard(container, node, editor, getPos)
289
+ return
290
+ }
291
+
292
+ if (!isLoaded) {
293
+ // Privacy placeholder
294
+ container.innerHTML = `
295
+ <div class="inkpen-embed__placeholder" style="--provider-color: ${providerConfig.color}">
296
+ <div class="inkpen-embed__placeholder-icon">${providerConfig.icon}</div>
297
+ <div class="inkpen-embed__placeholder-info">
298
+ <span class="inkpen-embed__placeholder-name">${providerConfig.name}</span>
299
+ <span class="inkpen-embed__placeholder-url">${extractDomain(node.attrs.url)}</span>
300
+ </div>
301
+ <button type="button" class="inkpen-embed__load-btn">Load ${providerConfig.name}</button>
302
+ </div>
303
+ `
304
+ container.querySelector(".inkpen-embed__load-btn")?.addEventListener("click", () => {
305
+ if (typeof getPos === "function") {
306
+ editor.chain().focus().updateAttributes("embed", { loaded: true }).run()
307
+ }
308
+ })
309
+ return
310
+ }
311
+
312
+ // Loaded embed
313
+ if (providerConfig.useWidget) {
314
+ renderWidget(container, node, providerConfig)
315
+ } else if (providerConfig.useScript) {
316
+ renderScript(container, node, providerConfig)
317
+ } else {
318
+ renderIframe(container, node, providerConfig)
319
+ }
320
+ }
321
+
322
+ renderEmbed()
323
+
324
+ return {
325
+ dom: container,
326
+ update: (updatedNode) => {
327
+ if (updatedNode.type.name !== "embed") return false
328
+ node = updatedNode
329
+ container.setAttribute("data-provider", node.attrs.provider || "link")
330
+ renderEmbed()
331
+ return true
332
+ },
333
+ selectNode: () => {
334
+ container.classList.add("is-selected")
335
+ },
336
+ deselectNode: () => {
337
+ container.classList.remove("is-selected")
338
+ }
339
+ }
340
+ }
341
+ },
342
+
343
+ addCommands() {
344
+ return {
345
+ insertEmbed: (url) => ({ commands, editor }) => {
346
+ const detected = detectProvider(url)
347
+
348
+ if (detected) {
349
+ const { provider, config, match } = detected
350
+ const embedData = config.extractId ? config.extractId(match) : match[1]
351
+
352
+ return commands.insertContent({
353
+ type: this.name,
354
+ attrs: {
355
+ url,
356
+ provider,
357
+ embedData,
358
+ loaded: !this.options.privacyMode
359
+ }
360
+ })
361
+ }
362
+
363
+ // Unknown URL - create link card
364
+ if (this.options.enableLinkCards) {
365
+ commands.insertContent({
366
+ type: this.name,
367
+ attrs: {
368
+ url,
369
+ provider: null,
370
+ linkCard: {
371
+ title: extractDomain(url),
372
+ description: null,
373
+ image: null,
374
+ domain: extractDomain(url)
375
+ },
376
+ loaded: true
377
+ }
378
+ })
379
+
380
+ // Fetch Open Graph data if fetcher provided
381
+ if (this.options.linkCardFetcher) {
382
+ this.options.linkCardFetcher(url).then(data => {
383
+ // Update the node with fetched data
384
+ // This would need to find the node by URL
385
+ }).catch(() => {
386
+ // Keep basic link card
387
+ })
388
+ }
389
+
390
+ return true
391
+ }
392
+
393
+ return false
394
+ },
395
+
396
+ loadEmbed: () => ({ commands }) => {
397
+ return commands.updateAttributes(this.name, { loaded: true })
398
+ },
399
+
400
+ setEmbedError: (error) => ({ commands }) => {
401
+ return commands.updateAttributes(this.name, { error })
402
+ }
403
+ }
404
+ },
405
+
406
+ addKeyboardShortcuts() {
407
+ return {
408
+ Backspace: ({ editor }) => {
409
+ const { selection } = editor.state
410
+ const node = selection.node
411
+
412
+ if (node?.type.name === this.name) {
413
+ return editor.commands.deleteSelection()
414
+ }
415
+ return false
416
+ },
417
+ Delete: ({ editor }) => {
418
+ const { selection } = editor.state
419
+ const node = selection.node
420
+
421
+ if (node?.type.name === this.name) {
422
+ return editor.commands.deleteSelection()
423
+ }
424
+ return false
425
+ }
426
+ }
427
+ },
428
+
429
+ addPasteRules() {
430
+ return []
431
+ },
432
+
433
+ addProseMirrorPlugins() {
434
+ const extension = this
435
+
436
+ return [
437
+ new Plugin({
438
+ key: EMBED_KEY,
439
+ props: {
440
+ handlePaste(view, event) {
441
+ const text = event.clipboardData?.getData("text/plain")
442
+ if (!text) return false
443
+
444
+ // Check if it's a URL
445
+ const urlPattern = /^https?:\/\/[^\s]+$/
446
+ if (!urlPattern.test(text.trim())) return false
447
+
448
+ const url = text.trim()
449
+ const detected = detectProvider(url)
450
+
451
+ // Only handle if it's a known provider or link cards are enabled
452
+ if (detected || extension.options.enableLinkCards) {
453
+ event.preventDefault()
454
+ extension.editor.commands.insertEmbed(url)
455
+ return true
456
+ }
457
+
458
+ return false
459
+ }
460
+ }
461
+ })
462
+ ]
463
+ }
464
+ })
465
+
466
+ // Helper: Render iframe embed
467
+ function renderIframe(container, node, config) {
468
+ const { url, embedData } = node.attrs
469
+ let embedUrl
470
+
471
+ if (config.useFullUrl) {
472
+ embedUrl = config.getEmbedUrl(url)
473
+ } else if (config.extractId) {
474
+ const match = [null, ...Object.values(embedData)]
475
+ embedUrl = config.getEmbedUrl(match)
476
+ } else {
477
+ embedUrl = config.getEmbedUrl(embedData)
478
+ }
479
+
480
+ const wrapper = document.createElement("div")
481
+ wrapper.className = "inkpen-embed__wrapper"
482
+
483
+ if (config.aspectRatio) {
484
+ wrapper.style.aspectRatio = config.aspectRatio
485
+ }
486
+ if (config.maxWidth) {
487
+ wrapper.style.maxWidth = `${config.maxWidth}px`
488
+ }
489
+ if (config.height) {
490
+ wrapper.style.height = `${config.height}px`
491
+ }
492
+
493
+ const iframe = document.createElement("iframe")
494
+ iframe.src = embedUrl
495
+ iframe.frameBorder = "0"
496
+ iframe.allowFullscreen = true
497
+ iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
498
+ iframe.loading = "lazy"
499
+
500
+ wrapper.appendChild(iframe)
501
+ container.appendChild(wrapper)
502
+ }
503
+
504
+ // Helper: Render widget (Twitter, Instagram)
505
+ function renderWidget(container, node, config) {
506
+ const wrapper = document.createElement("div")
507
+ wrapper.className = "inkpen-embed__wrapper inkpen-embed__wrapper--widget"
508
+
509
+ if (config.name === "Twitter") {
510
+ const { user, id } = node.attrs.embedData
511
+ wrapper.innerHTML = `
512
+ <blockquote class="twitter-tweet" data-conversation="none">
513
+ <a href="https://twitter.com/${user}/status/${id}">Loading tweet...</a>
514
+ </blockquote>
515
+ `
516
+ loadScript(config.widgetScript, () => {
517
+ if (window.twttr?.widgets) {
518
+ window.twttr.widgets.load(wrapper)
519
+ }
520
+ })
521
+ } else if (config.name === "Instagram") {
522
+ const { type, id } = node.attrs.embedData
523
+ wrapper.innerHTML = `
524
+ <blockquote class="instagram-media" data-instgrm-captioned
525
+ data-instgrm-permalink="https://www.instagram.com/${type}/${id}/">
526
+ <a href="https://www.instagram.com/${type}/${id}/">Loading Instagram...</a>
527
+ </blockquote>
528
+ `
529
+ loadScript(config.widgetScript, () => {
530
+ if (window.instgrm?.Embeds) {
531
+ window.instgrm.Embeds.process(wrapper)
532
+ }
533
+ })
534
+ }
535
+
536
+ container.appendChild(wrapper)
537
+ }
538
+
539
+ // Helper: Render script embed (Gist)
540
+ function renderScript(container, node, config) {
541
+ const wrapper = document.createElement("div")
542
+ wrapper.className = "inkpen-embed__wrapper inkpen-embed__wrapper--script"
543
+
544
+ const { user, id } = node.attrs.embedData
545
+ const scriptUrl = config.getScriptUrl([null, user, id])
546
+
547
+ // Create iframe to sandbox the gist
548
+ const iframe = document.createElement("iframe")
549
+ iframe.className = "inkpen-embed__gist-frame"
550
+ iframe.frameBorder = "0"
551
+ iframe.scrolling = "no"
552
+
553
+ iframe.onload = () => {
554
+ const doc = iframe.contentDocument
555
+ if (doc) {
556
+ doc.open()
557
+ doc.write(`
558
+ <!DOCTYPE html>
559
+ <html>
560
+ <head>
561
+ <base target="_blank">
562
+ <style>
563
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
564
+ .gist .gist-file { border: none !important; }
565
+ .gist .gist-meta { display: none !important; }
566
+ </style>
567
+ </head>
568
+ <body>
569
+ <script src="${scriptUrl}"></script>
570
+ </body>
571
+ </html>
572
+ `)
573
+ doc.close()
574
+
575
+ // Resize iframe to content
576
+ setTimeout(() => {
577
+ const height = doc.body.scrollHeight
578
+ iframe.style.height = `${height}px`
579
+ }, 500)
580
+ }
581
+ }
582
+
583
+ wrapper.appendChild(iframe)
584
+ container.appendChild(wrapper)
585
+ }
586
+
587
+ // Helper: Render link card
588
+ function renderLinkCard(container, node, editor, getPos) {
589
+ const { url, linkCard } = node.attrs
590
+ const data = linkCard || { domain: extractDomain(url) }
591
+
592
+ container.innerHTML = `
593
+ <a href="${url}" target="_blank" rel="noopener noreferrer" class="inkpen-link-card">
594
+ ${data.image ? `<img src="${data.image}" alt="" class="inkpen-link-card__image">` : ""}
595
+ <div class="inkpen-link-card__content">
596
+ <span class="inkpen-link-card__title">${data.title || url}</span>
597
+ ${data.description ? `<span class="inkpen-link-card__description">${data.description}</span>` : ""}
598
+ <span class="inkpen-link-card__domain">
599
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
600
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
601
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
602
+ </svg>
603
+ ${data.domain}
604
+ </span>
605
+ </div>
606
+ </a>
607
+ `
608
+ }
609
+
610
+ // Helper: Load external script
611
+ const loadedScripts = new Set()
612
+
613
+ function loadScript(src, callback) {
614
+ if (loadedScripts.has(src)) {
615
+ callback?.()
616
+ return
617
+ }
618
+
619
+ const script = document.createElement("script")
620
+ script.src = src
621
+ script.async = true
622
+ script.onload = () => {
623
+ loadedScripts.add(src)
624
+ callback?.()
625
+ }
626
+ document.body.appendChild(script)
627
+ }
628
+
629
+ export default Embed