1/** 2 * embed-block.js: the website editor's Embed block (vault1, 2026-09-29). 3 * 4 * "Paste a link or embed code". The coach pastes whatever the other site gave 5 * them; POST /api/embeds/resolve recognises the provider and hands back OUR 6 * markup (src/lib/embed-resolve.js): the click-to-play video facade for a 7 * video, a sandboxed frame for everything else. A site that is not on the 8 * trusted list becomes a link card, never raw HTML (Alex: "no raw HTML ever"). 9 * 10 * Loaded by grapes-tw.html after grapes-tw-components.js. Double-click an 11 * embed on the canvas to change it. 12 */ 13(function () { 14 'use strict'; 15 16 var PLACEHOLDER = '<div class="rk-embed rk-embed--empty" data-embed-empty="1" style="max-width:800px;margin:24px auto;padding:36px 20px;border:2px dashed rgba(120,120,140,.45);border-radius:12px;text-align:center;font:500 15px/1.4 Inter,system-ui,sans-serif;color:#6b7280;">Paste a link or embed code to show it here</div>'; 17 var providers = null; 18 19 function esc(s) { 20 return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 21 } 22 // The editor has getAuthHeaders(); the app (the Vault library screens, 23 // which reuse this box) has the same token in storage. 24 function headers() { 25 var h = (typeof getAuthHeaders === 'function') ? getAuthHeaders({}) : {}; 26 if (!h.Authorization) { 27 var t = null; 28 try { t = (window.SharedAuth && SharedAuth.getToken && SharedAuth.getToken()) || localStorage.getItem('cb_token') || localStorage.getItem('coachbot_token'); } catch (_) { t = null; } 29 if (t) h.Authorization = 'Bearer ' + t; 30 } 31 h['Content-Type'] = 'application/json'; 32 return h; 33 } 34 35 function loadProviders() { 36 if (providers) return Promise.resolve(providers); 37 return fetch('/api/embeds/providers', { headers: headers() }) 38 .then(function (r) { return r.ok ? r.json() : { groups: [] }; }) 39 .then(function (d) { providers = d.groups || []; return providers; }) 40 .catch(function () { return []; }); 41 } 42 43 function styleOnce() { 44 if (document.getElementById('rk-embed-modal-css')) return; 45 var st = document.createElement('style'); 46 st.id = 'rk-embed-modal-css'; 47 st.textContent = 48 '.rkem-back{position:fixed;inset:0;background:rgba(10,10,20,.55);z-index:100000;display:flex;align-items:center;justify-content:center;padding:16px}' + 49 '.rkem{background:#fff;color:#111827;width:100%;max-width:560px;max-height:calc(100vh - 32px);overflow:auto;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.35);font:14px/1.5 Inter,system-ui,sans-serif}' + 50 '.rkem-h{display:flex;align-items:center;justify-content:space-between;padding:18px 20px 6px}' + 51 '.rkem-h h2{margin:0;font-size:18px;font-weight:700}' + 52 '.rkem-x{background:none;border:0;font-size:22px;line-height:1;cursor:pointer;color:#6b7280;padding:4px 8px}' + 53 '.rkem-b{padding:6px 20px 20px}' + 54 '.rkem label{display:block;font-weight:600;margin:8px 0 6px}' + 55 '.rkem textarea{width:100%;box-sizing:border-box;min-height:92px;border:1px solid #d1d5db;border-radius:10px;padding:10px 12px;font:14px/1.45 ui-monospace,Menlo,monospace;resize:vertical}' + 56 '.rkem textarea:focus{outline:2px solid #6c63ff;outline-offset:1px;border-color:transparent}' + 57 '.rkem-hint{color:#6b7280;font-size:13px;margin:8px 0 0}' + 58 '.rkem-hint button{background:none;border:0;padding:0;color:#4f46e5;cursor:pointer;font:inherit;text-decoration:underline}' + 59 '.rkem-list{margin:10px 0 0;padding:10px 12px;background:#f9fafb;border-radius:10px;font-size:13px;color:#374151}' + 60 '.rkem-list div{margin:2px 0}.rkem-list b{font-weight:600}' + 61 '.rkem-msg{min-height:20px;margin:12px 0 0;font-size:13px}' + 62 '.rkem-msg.err{color:#b91c1c}' + 63 '.rkem-card{margin-top:12px}' + 64 '.rkem-ask{margin-top:12px;padding:12px 14px;border-radius:10px;background:#f5f3ff}' + 65 '.rkem-ask-t{margin:0 0 8px;font-size:13px;color:#374151}' + 66 '.rkem-ask .rkem-btn{background:#fff;border:1px solid #c7d2fe}' + 67 '.rkem-f{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;padding:0 20px 20px}' + 68 '.rkem-btn{border:0;border-radius:10px;padding:10px 16px;font:600 14px Inter,system-ui,sans-serif;cursor:pointer}' + 69 '.rkem-btn.pri{background:#6c63ff;color:#fff}.rkem-btn.sec{background:#f3f4f6;color:#111827}' + 70 '.rkem-btn[disabled]{opacity:.6;cursor:default}'; 71 document.head.appendChild(st); 72 } 73 74 /** 75 * Open the paste box. onDone(html) receives the markup to put on the page. 76 * Exposed as window.rkEmbedPrompt so the Vault's Embed item can reuse it. 77 */ 78 function openEmbedPrompt(opts) { 79 opts = opts || {}; 80 styleOnce(); 81 var back = document.createElement('div'); 82 back.className = 'rkem-back'; 83 back.innerHTML = 84 '<div class="rkem" role="dialog" aria-modal="true" aria-labelledby="rkem-title">' + 85 '<div class="rkem-h"><h2 id="rkem-title">Embed something</h2><button type="button" class="rkem-x" aria-label="Close">Ã</button></div>' + 86 '<div class="rkem-b">' + 87 '<label for="rkem-in">Paste a link or the embed code</label>' + 88 '<textarea id="rkem-in" placeholder="https://www.youtube.com/watch?v=..." spellcheck="false">' + esc(opts.value || '') + '</textarea>' + 89 '<p class="rkem-hint">Works with YouTube, Vimeo, Loom, Calendly, Canva, Typeform, Spotify and more. <button type="button" data-rkem-all>See every site</button></p>' + 90 '<div class="rkem-list" data-rkem-list hidden></div>' + 91 '<div class="rkem-msg" data-rkem-msg role="status" aria-l
91ive="polite"></div>' + 92 '<div class="rkem-card" data-rkem-card hidden></div>' + 93 '<div class="rkem-ask" data-rkem-ask hidden>' + 94 '<p class="rkem-ask-t" data-rkem-ask-t></p>' + 95 '<button type="button" class="rkem-btn sec" data-rkem-askbtn>Ask us to support this site</button>' + 96 '</div>' + 97 '</div>' + 98 '<div class="rkem-f">' + 99 '<button type="button" class="rkem-btn sec" data-rkem-cancel>Cancel</button>' + 100 '<button type="button" class="rkem-btn sec" data-rkem-asacard hidden>Add it as a link</button>' + 101 '<button type="button" class="rkem-btn pri" data-rkem-go>Embed</button>' + 102 '</div>' + 103 '</div>'; 104 document.body.appendChild(back); 105 var input = back.querySelector('#rkem-in'); 106 var msg = back.querySelector('[data-rkem-msg]'); 107 var cardBox = back.querySelector('[data-rkem-card]'); 108 var go = back.querySelector('[data-rkem-go]'); 109 var asCard = back.querySelector('[data-rkem-asacard]'); 110 var lastCardHtml = ''; 111 var lastCardUrl = ''; 112 var ask = back.querySelector('[data-rkem-ask]'); 113 var askT = back.querySelector('[data-rkem-ask-t]'); 114 var askBtn = back.querySelector('[data-rkem-askbtn]'); 115 var askUrl = ''; 116 // "Ask us to support this site" (vault1 phase 3). It lands in the team's 117 // Feedback Desk; the coach is told what happens next, in plain words, and 118 // a second ask for the same site says it is already on its way. 119 function showAsk(d) { 120 askUrl = d.url || ''; 121 if (d.request) { 122 askT.textContent = d.request_message || ''; 123 askBtn.hidden = true; 124 } else { 125 askT.textContent = 'Want it shown right on the page? Ask us to add it. Requested sites are ' + (d.typical || 'normally added within 48 hours') + '.'; 126 askBtn.hidden = false; askBtn.disabled = false; askBtn.textContent = 'Ask us to support this site'; 127 } 128 ask.hidden = false; 129 } 130 askBtn.onclick = function () { 131 if (!askUrl) return; 132 askBtn.disabled = true; askBtn.textContent = 'Sending\u2026'; 133 fetch('/api/embeds/requests', { method: 'POST', headers: headers(), body: JSON.stringify({ url: askUrl, context: opts.context || 'website' }) }) 134 .then(function (r) { return r.json(); }) 135 .then(function (d) { 136 if (d && d.ok) { askT.textContent = d.message || ''; askBtn.hidden = true; return; } 137 askBtn.disabled = false; askBtn.textContent = 'Ask us to support this site'; 138 askT.textContent = (d && d.message) || 'We could not send that just now. Please try again in a minute.'; 139 }) 140 .catch(function () { 141 askBtn.disabled = false; askBtn.textContent = 'Ask us to support this site'; 142 askT.textContent = 'We could not send that just now. Please try again in a minute.'; 143 }); 144 }; 145 function close() { if (back.parentNode) back.parentNode.removeChild(back); if (opts.onCancel) opts.onCancel(); } 146 function say(t, err) { msg.textContent = t || ''; msg.className = 'rkem-msg' + (err ? ' err' : ''); } 147 back.querySelector('.rkem-x').onclick = close; 148 back.querySelector('[data-rkem-cancel]').onclick = close; 149 back.addEventListener('click', function (e) { if (e.target === back) close(); }); 150 back.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); 151 back.querySelector('[data-rkem-all]').onclick = function () { 152 var list = back.querySelector('[data-rkem-list]'); 153 if (!list.hidden) { list.hidden = true; return; } 154 loadProviders().then(function (groups) { 155 list.innerHTML = groups.map(function (g) { 156 return '<div><b>' + esc(g.label) + ':</b> ' + g.providers.map(function (p) { return esc(p.name); }).join(', ') + '</div>'; 157 }).join('') || 'Could not load the list just now.'; 158 list.hidden = false; 159 }); 160 }; 161 asCard.onclick = function () { 162 if (!lastCardHtml) return; 163 if (opts.onDone) opts.onDone(lastCardHtml, { kind: 'card', url: lastCardUrl }); 164 opts.onCancel = null; close(); 165 }; 166 go.onclick = function () { 167 var v = input.value.trim(); 168 if (!v) { say('Paste a link first.', true); input.focus(); return; } 169 go.disabled = true; say('Checking the linkâ¦'); 170 cardBox.hidden = true; asCard.hidden = true; ask.hidden = true; lastCardHtml = ''; lastCardUrl = ''; 171 fetch('/api/embeds/resolve', { method: 'POST', headers: headers(), body: JSON.stringify({ input: v }) }) 172 .then(function (r) { return r.json().then(function (d) { return { status: r.status, d: d }; }); }) 173 .then(function (res) { 174 go.disabled = false;
175 var d = res.d || {}; 176 if (d.ok && d.html) { 177 if (opts.onDone) opts.onDone(d.html, { kind: 'embed', provider: d.provider, name: d.name, result: d }); 178 opts.onCancel = null; close(); 179 return; 180 } 181 if (d.reason === 'unsupported') { 182 say((d.host || 'That site') + ' is not one we can show inside your page yet. You can add it as a link, and it will open in a new tab.'); 183 lastCardHtml = d.card_html || ''; 184 lastCardUrl = d.url || ''; 185 if (lastCardHtml) { cardBox.innerHTML = lastCardHtml; cardBox.hidden = false; asCard.hidden = false; } 186 if (d.can_request) showAsk(d); 187 // Nothing more "Embed" can do with this paste (round 7): the button 188 // rests until the coach pastes something else. 189 go.disabled = true; 190 if (opts.onUnsupported) opts.onUnsupported(d, back); 191 return; 192 } 193 if (d.reason === 'not_a_link' || d.reason === 'empty') { 194 say('That does not look like a link. Paste the address from your browser, or the embed code the site gives you.', true); 195 return; 196 } 197 say('Something went wrong checking that link. Please try again.', true); 198 }) 199 .catch(function () { go.disabled = false; say('Something went wrong checking that link. Please try again.', true); }); 200 }; 201 input.addEventListener('input', function () { go.disabled = false; }); 202 setTimeout(function () { input.focus(); }, 30); 203 return back; 204 } 205 window.rkEmbedPrompt = openEmbedPrompt; 206 207 function isEmbedEl(el) { 208 return !!(el && el.classList && (el.classList.contains('rk-embed') || el.classList.contains('rk-embed-card'))); 209 } 210 211 window.initEmbedBlock = function (ed) { 212 if (!ed || ed.__rkEmbed) return; 213 ed.__rkEmbed = true; 214 var loaded = false; 215 216 ed.DomComponents.addType('rk-embed', { 217 isComponent: function (el) { return isEmbedEl(el) ? { type: 'rk-embed' } : false; }, 218 model: { defaults: { droppable: false, editable: false, copyable: true, name: 'Embed' } }, 219 }); 220 221 ed.BlockManager.add('embed-block', { 222 label: 'Embed', category: 'ð¤ Basic', 223 media: '<svg viewBox="0 0 24 24" width="44" height="44" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 9.5 7 12l2.5 2.5M14.5 9.5 17 12l-2.5 2.5"/></svg>', 224 content: PLACEHOLDER, 225 }); 226 227 function edit(comp, value) { 228 openEmbedPrompt({ 229 value: value || '', 230 onDone: function (html) { 231 try { 232 var added = comp.replaceWith(html); 233 var first = Array.isArray(added) ? added[0] : added; 234 if (first && ed.select) ed.select(first); 235 } catch (e) { console.error('[embed-block] could not place the embed:', e); } 236 }, 237 }); 238 } 239 240 function compFrom(el) { 241 for (var n = el; n; n = n.parentNode) { 242 if (isEmbedEl(n)) { 243 var v = n.__gjsv; 244 if (v && v.model) return v.model; 245 } 246 } 247 return null; 248 } 249 250 ed.on('load', function () { loaded = true; wire(); }); 251 ed.on('canvas:frame:load', wire); 252 function wire() { 253 try { 254 var doc = ed.Canvas.getDocument(); 255 if (!doc || doc.__rkEmbedWired) return; 256 doc.__rkEmbedWired = true; 257 // Capture phase, so an embedded video's double-click opens THIS box 258 // rather than the video library's picker. 259 doc.addEventListener('dblclick', function (e) { 260 var comp = compFrom(e.target); 261 if (!comp) return; 262 e.stopImmediatePropagation(); e.preventDefault(); 263 var el = comp.getEl && comp.getEl(); 264 var src = el && (el.getAttribute('data-embed-source') || el.getAttribute('data-video-url')) || ''; 265 edit(comp, src); 266 }, true); 267 } catch (e) { /* canvas not ready yet: load / frame-load wire it */ } 268 } 269 270 // A freshly dropped Embed block asks for its link straight away. 271 ed.on('component:add', function (comp) {
272 try { 273 if (!loaded) return; 274 var el = comp.getEl && comp.getEl(); 275 if (!el || !el.getAttribute || el.getAttribute('data-embed-empty') !== '1') return; 276 // Cancelled before anything was pasted: the empty box goes, so a 277 // dashed "paste here" can never reach a published page. 278 setTimeout(function () { 279 openEmbedPrompt({ 280 value: '', 281 onDone: function (html) { 282 try { var added = comp.replaceWith(html); var first = Array.isArray(added) ? added[0] : added; if (first && ed.select) ed.select(first); } 283 catch (e2) { console.error('[embed-block] could not place the embed:', e2); } 284 }, 285 onCancel: function () { try { comp.remove(); } catch (_) {} }, 286 }); 287 }, 120); 288 } catch (e) { /* ignore */ } 289 }); 290 }; 291 292 if (typeof module !== 'undefined' && module.exports) module.exports = { openEmbedPrompt: openEmbedPrompt, PLACEHOLDER: PLACEHOLDER }; 293})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.