|
1432 | 1432 | border: 1px solid var(--vscode-inputValidation-warningBorder, rgba(224,160,48,.45)); |
1433 | 1433 | } |
1434 | 1434 | .lcd-banner-text { flex: 1 1 12em; min-width: 0; } |
1435 | | - .lcd-stage { cursor: zoom-in; border-radius: 8px; outline-offset: 3px; } |
1436 | | - .lcd-stage:focus-visible { outline: 1px solid var(--accent); } |
| 1435 | + /* A click on the picture opens it full size — a shortcut for the pointer only. The picture itself is |
| 1436 | + not a control (its linked nodes are, and a control inside a control is nobody's to announce); the |
| 1437 | + toolbar's "Full size" button is the way in for the keyboard and for a screen reader. */ |
| 1438 | + .lcd-stage { cursor: zoom-in; border-radius: 8px; } |
1437 | 1439 | /* "Never wider than the chat column": the picture is laid out to fit, and scales down if the column narrows. */ |
1438 | 1440 | .lcd-stage svg { display: block; max-width: 100%; height: auto; } |
1439 | 1441 | .lcd-btn { |
|
4304 | 4306 | // SVG elements from a fixed allow-list and sets text with textContent. The card chrome below is |
4305 | 4307 | // built with createElement + textContent too. Nothing from a spec is ever assigned to innerHTML. |
4306 | 4308 | const LCD = window.LCDiagram || null; // null if the modules did not load → the host's text version is shown instead |
4307 | | - const lcdCards = {}; // tool-call key → the card that shows (or will show) that diagram |
| 4309 | + // tool-call key → the card that shows (or will show) that diagram. The key is whatever the provider |
| 4310 | + // called its tool call, so the table inherits nothing: `constructor` and `__proto__` are just keys. |
| 4311 | + const lcdCards = Object.create(null); |
4308 | 4312 | let lcdSeq = 0; |
4309 | 4313 | const lcdCtx = (function(){ try { return document.createElement('canvas').getContext('2d'); } catch (e) { return null; } })(); |
4310 | 4314 | function lcdFont(){ try { return getComputedStyle(document.body).fontFamily || 'sans-serif'; } catch (e) { return 'sans-serif'; } } |
|
4372 | 4376 | card.appendChild(skel); |
4373 | 4377 | scrollIfStuck(); |
4374 | 4378 | } |
4375 | | - /** A finished record from the host: fill the placeholder that was waiting for it, or make a card. */ |
| 4379 | + /** Take a card out of the transcript — and the bubble that held it, if nothing else is in it. */ |
| 4380 | + function lcdDrop(card){ |
| 4381 | + const body = card.parentElement, msg = body && body.parentElement; |
| 4382 | + card.remove(); |
| 4383 | + Object.keys(lcdCards).forEach(function(k){ if (lcdCards[k] === card){ delete lcdCards[k]; } }); |
| 4384 | + if (body && !body.childElementCount && !body.textContent.trim() && msg && msg.classList.contains('msg')){ msg.remove(); } |
| 4385 | + } |
| 4386 | + /** |
| 4387 | + * A finished record from the host: fill the placeholder that was waiting for it, or make a card. |
| 4388 | + * One diagram, one card — the same cards a reopened session shows (sessionEvents.toDisplayTurns). |
| 4389 | + * A record has a claim on a card that is still a placeholder, or that shows the drawing it replaces, |
| 4390 | + * and on no card that shows a different diagram. |
| 4391 | + */ |
4376 | 4392 | function lcdShow(m){ |
4377 | 4393 | const record = m && m.record; if (!record || !record.id){ return; } |
4378 | 4394 | const key = m.key || record.key || record.id; |
4379 | | - let card = lcdCards[key] || (m.replacesKey ? lcdCards[m.replacesKey] : null); |
4380 | | - if (card && !card.isConnected){ card = null; } |
| 4395 | + const there = function(c){ return c && c.isConnected ? c : null; }; |
| 4396 | + const takes = function(c){ return !!c && (!c.dataset.id || c.dataset.id === record.id || c.dataset.id === String(record.replaces || '')); }; |
| 4397 | + let card = there(lcdCards[key]); |
| 4398 | + const old = m.replacesKey ? there(lcdCards[m.replacesKey]) : null; |
| 4399 | + // The placeholder this call took over when it began may have been filled since: the host settled |
| 4400 | + // the diagram it was holding a place for, and this is a different one. That one keeps its card. |
| 4401 | + if (!takes(card)){ card = null; } |
| 4402 | + // A repair is drawn where the failed attempt was waiting. A redraw stands where the model drew it |
| 4403 | + // again, and the earlier drawing goes — as it does when the chat is reopened. |
| 4404 | + if (!card && old && !old.dataset.id){ card = old; } |
4381 | 4405 | if (!card){ card = lcdHost(key); } |
| 4406 | + if (old && old !== card && takes(old)){ lcdDrop(old); } |
4382 | 4407 | lcdCards[key] = card; |
4383 | 4408 | lcdDraw(card, record, false); |
4384 | 4409 | scrollIfStuck(); |
|
4439 | 4464 | const descId = 'lcd-desc-' + (++lcdSeq); |
4440 | 4465 | svg.setAttribute('aria-describedby', descId); |
4441 | 4466 | stage.appendChild(svg); |
4442 | | - stage.tabIndex = 0; stage.setAttribute('role', 'button'); stage.setAttribute('aria-label', 'Open the diagram full size'); stage.title = 'Click to open full size'; |
| 4467 | + stage.title = 'Click to open full size'; |
4443 | 4468 | const desc = lcdEl('p', 'lcd-sr', LCD.text.outline(spec).text); desc.id = descId; card.appendChild(desc); |
4444 | 4469 | card._spec = spec; card._geo = geo; |
4445 | 4470 | card.appendChild(lcdTools(card, record, false)); |
|
4527 | 4552 | img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.text); |
4528 | 4553 | }); |
4529 | 4554 | } |
4530 | | - /** Copy source · SVG · PNG · Open as Mermaid · Insert into Markdown. */ |
| 4555 | + /** Full size · Copy source · SVG · PNG · Open as Mermaid · Insert into Markdown. */ |
4531 | 4556 | function lcdTools(card, record, textOnly){ |
4532 | 4557 | const bar = lcdEl('div', 'lcd-tools'); |
4533 | 4558 | const fail = function(e){ vscode.postMessage({ type: 'notice', text: 'The diagram could not be exported: ' + String((e && e.message) || e) }); }; |
| 4559 | + if (!textOnly){ bar.appendChild(lcdBtn('Full size', 'Open the diagram full size, to zoom and pan', function(){ lcdZoomOpen(card); })); } |
4534 | 4560 | const copy = lcdBtn('Copy source', 'Copy the diagram’s spec', function(){ lcdAct(record.id, 'export', { format: 'source' }); lcdFlash(copy, 'Copied'); }); |
4535 | 4561 | bar.appendChild(copy); |
4536 | 4562 | if (!textOnly){ |
|
4556 | 4582 | function lcdSweep(){ |
4557 | 4583 | Object.keys(lcdCards).forEach(function(k){ |
4558 | 4584 | const card = lcdCards[k]; |
4559 | | - if (card.dataset.id){ return; } |
4560 | | - const body = card.parentElement, msg = body && body.parentElement; |
4561 | | - card.remove(); delete lcdCards[k]; |
4562 | | - if (body && !body.childElementCount && !body.textContent.trim() && msg && msg.classList.contains('msg')){ msg.remove(); } |
| 4585 | + if (card && !card.dataset.id){ lcdDrop(card); } |
4563 | 4586 | }); |
4564 | 4587 | } |
4565 | 4588 | function lcdReset(){ Object.keys(lcdCards).forEach(function(k){ delete lcdCards[k]; }); lcdZoomClose(); } |
4566 | 4589 |
|
4567 | | - // Full-size view: zoom about the pointer, drag to pan, fit and 100%. |
4568 | | - const lcdZ = { el: document.getElementById('lcdZoom'), view: null, svg: null, card: null, k: 1, x: 0, y: 0, w: 0, h: 0, back: null }; |
| 4590 | + // Full-size view: zoom about the pointer, drag to pan, fit and 100%. It says it is modal, so it is: |
| 4591 | + // while it is open the rest of the page is inert — not focusable, not clickable, not read out — and |
| 4592 | + // Tab goes round inside it. Closing gives the page back, and the focus to where it was. |
| 4593 | + const lcdZ = { el: document.getElementById('lcdZoom'), view: null, svg: null, card: null, k: 1, x: 0, y: 0, w: 0, h: 0, back: null, behind: [] }; |
4569 | 4594 | lcdZ.view = lcdZ.el.querySelector('.lcdz-view'); |
4570 | 4595 | function lcdZoomApply(){ if (lcdZ.svg){ lcdZ.svg.style.transform = 'translate(' + lcdZ.x + 'px,' + lcdZ.y + 'px) scale(' + lcdZ.k + ')'; } } |
4571 | 4596 | function lcdZoomFit(){ |
|
4582 | 4607 | lcdZ.view.textContent = ''; |
4583 | 4608 | const svg = src.cloneNode(true); |
4584 | 4609 | svg.removeAttribute('aria-describedby'); |
4585 | | - lcdZ.w = card._geo.width; lcdZ.h = card._geo.height; lcdZ.svg = svg; lcdZ.card = card; lcdZ.back = document.activeElement; |
| 4610 | + lcdZ.w = card._geo.width; lcdZ.h = card._geo.height; lcdZ.svg = svg; lcdZ.card = card; |
| 4611 | + if (lcdZ.el.hidden){ |
| 4612 | + lcdZ.back = document.activeElement; |
| 4613 | + lcdZ.behind = Array.prototype.filter.call(document.body.children, function(n){ return n !== lcdZ.el && !n.inert && !/^(SCRIPT|STYLE|TEMPLATE)$/.test(n.tagName); }); |
| 4614 | + lcdZ.behind.forEach(function(n){ n.inert = true; }); |
| 4615 | + } |
4586 | 4616 | svg.style.width = lcdZ.w + 'px'; svg.style.height = lcdZ.h + 'px'; |
4587 | 4617 | lcdZ.view.appendChild(svg); |
4588 | 4618 | lcdZ.el.querySelector('.lcdz-title').textContent = (card._spec && card._spec.title) || 'Diagram'; |
|
4593 | 4623 | function lcdZoomClose(){ |
4594 | 4624 | if (!lcdZ.el || lcdZ.el.hidden){ return; } |
4595 | 4625 | lcdZ.el.hidden = true; lcdZ.view.textContent = ''; lcdZ.svg = null; lcdZ.card = null; |
| 4626 | + lcdZ.behind.forEach(function(n){ n.inert = false; }); lcdZ.behind = []; // before the focus goes back: an inert element cannot take it |
4596 | 4627 | try { if (lcdZ.back && lcdZ.back.focus){ lcdZ.back.focus(); } } catch (e) {} |
| 4628 | + lcdZ.back = null; |
| 4629 | + } |
| 4630 | + /** Where Tab stops in the full-size view: its buttons, then the picture's linked nodes. */ |
| 4631 | + function lcdZoomStops(){ return Array.prototype.slice.call(lcdZ.el.querySelectorAll('button:not([disabled]), [data-lc-link]')); } |
| 4632 | + /** |
| 4633 | + * Tab in the full-size view. Returns where the focus has to be PUT: past the last stop is the first, |
| 4634 | + * before the first is the last, and from anywhere outside the view it is brought back in. Null when |
| 4635 | + * the browser's own next stop is already inside, and Tab can be left to it. |
| 4636 | + */ |
| 4637 | + function lcdZoomTab(stops, active, back){ |
| 4638 | + const at = stops.indexOf(active); |
| 4639 | + if (at >= 0 && at !== (back ? 0 : stops.length - 1)){ return null; } |
| 4640 | + return stops[back ? stops.length - 1 : 0] || active || null; |
4597 | 4641 | } |
4598 | 4642 | lcdZ.el.querySelector('.lcdz-bar').addEventListener('click', function(e){ |
4599 | 4643 | const b = e.target.closest && e.target.closest('[data-z]'); if (!b){ return; } |
|
4623 | 4667 | document.addEventListener('keydown', function(e){ |
4624 | 4668 | if (lcdZ.el.hidden){ return; } |
4625 | 4669 | const r = lcdZ.view.getBoundingClientRect(); |
| 4670 | + const link = e.target && e.target.closest && lcdZ.view.contains(e.target) ? e.target.closest('[data-lc-link]') : null; |
4626 | 4671 | if (e.key === 'Escape'){ e.stopPropagation(); e.preventDefault(); lcdZoomClose(); } |
| 4672 | + else if (e.key === 'Tab'){ const to = lcdZoomTab(lcdZoomStops(), document.activeElement, e.shiftKey); if (to){ e.preventDefault(); to.focus(); } } |
| 4673 | + else if ((e.key === 'Enter' || e.key === ' ') && link && lcdZ.card){ e.preventDefault(); lcdAct(lcdZ.card.dataset.id, 'openLink', { node: link.getAttribute('data-lc-link') }); } |
4627 | 4674 | else if (e.key === '+' || e.key === '='){ lcdZoomBy(1.25, r.width / 2, r.height / 2); } |
4628 | 4675 | else if (e.key === '-'){ lcdZoomBy(0.8, r.width / 2, r.height / 2); } |
4629 | 4676 | else if (e.key === '0'){ lcdZoomFit(); } |
4630 | 4677 | else if (e.key === '1'){ lcdZoomBy(1 / lcdZ.k, r.width / 2, r.height / 2); } |
4631 | 4678 | }, true); |
4632 | | - // In the transcript: a linked node opens its file; anywhere else on the picture opens it full size. |
| 4679 | + // In the transcript: a linked node opens its file — by click, or by Enter/Space when it has the |
| 4680 | + // focus; a click anywhere else on the picture opens it full size. |
4633 | 4681 | // The webview only ever names the NODE — which file that means is the host's own record. |
4634 | 4682 | function lcdActivate(e){ |
4635 | 4683 | const t = e.target; if (!t || !t.closest){ return false; } |
|
4640 | 4688 | return false; |
4641 | 4689 | } |
4642 | 4690 | log.addEventListener('click', function(e){ if (lcdActivate(e)){ e.preventDefault(); } }); |
4643 | | - log.addEventListener('keydown', function(e){ if ((e.key === 'Enter' || e.key === ' ') && e.target && e.target.closest && e.target.closest('[data-lc-link], .lcd-stage') && lcdActivate(e)){ e.preventDefault(); } }); |
| 4691 | + log.addEventListener('keydown', function(e){ if ((e.key === 'Enter' || e.key === ' ') && e.target && e.target.closest && e.target.closest('[data-lc-link]') && lcdActivate(e)){ e.preventDefault(); } }); |
4644 | 4692 | // The column changed width: re-lay out any diagram that would now turn (or stop needing to). |
4645 | 4693 | let lcdResizeT = null; |
4646 | 4694 | window.addEventListener('resize', function(){ |
|
0 commit comments