diff --git a/app/changes.html b/app/changes.html new file mode 100644 index 00000000..e8c2adf5 --- /dev/null +++ b/app/changes.html @@ -0,0 +1,25 @@ + + +Change tracking fixture + + +
+

Welcome

+ + Read docs +
+ +
+
one
+
two
+
three
+
+ + + diff --git a/app/components/hotkey-map/base.element.js b/app/components/hotkey-map/base.element.js index 83d26a08..9ac09501 100644 --- a/app/components/hotkey-map/base.element.js +++ b/app/components/hotkey-map/base.element.js @@ -87,8 +87,16 @@ export class HotkeyMap extends HTMLElement { }) } + /** + * What one press is worth. Overridden by tools that walk a token scale, + * where "by 1" would be a lie. + */ + amountFor({hotkeys}) { + return hotkeys.shift ? 10 : 1 + } + createCommand({e:{code}, hotkeys}) { - let amount = hotkeys.shift ? 10 : 1 + let amount = this.amountFor({hotkeys}) let negative = hotkeys.alt ? 'Subtract' : 'Add' let negative_modifier = hotkeys.alt ? 'from' : 'to' diff --git a/app/components/hotkey-map/boxshadow.element.js b/app/components/hotkey-map/boxshadow.element.js index 757a48a8..977fdc66 100644 --- a/app/components/hotkey-map/boxshadow.element.js +++ b/app/components/hotkey-map/boxshadow.element.js @@ -1,33 +1,72 @@ import { HotkeyMap } from './base.element' -import { boxshadow as icon } from '../vis-bug/vis-bug.icons' -import { metaKey } from '../../utilities'; +import { metaKey, altKey } from '../../utilities'; +import { hasScale } from '../../core' export class BoxshadowHotkeys extends HotkeyMap { constructor() { super() this._hotkey = 'd' - this._usedkeys = ['shift',metaKey] + this._usedkeys = ['shift',metaKey,altKey,'[',']'] this.tool = 'boxshadow' } - show() { - this.$shadow.host.style.display = 'flex' + createCommand({e:{code}, hotkeys}) { + let amount = hotkeys.shift ? 10 : 1 + let negative = '[increase/decrease]' + let negative_modifier = 'by' + let side = '[arrow key]' + + // whole-value tokens: a design system hands you the shadow, not its parts + if (code === 'BracketLeft' || code === 'BracketRight') { + side = hotkeys.shift ? 'border radius' : 'box shadow' + + if (hasScale(hotkeys.shift ? 'radius' : 'shadow')) { + amount = 'one scale step' + negative = code === 'BracketRight' ? 'increase' : 'decrease' + } + else { + negative = 'this page defines no' + negative_modifier = '' + amount = 'scale' + } + } + else if (hotkeys[metaKey] && (code === 'ArrowLeft' || code === 'ArrowRight')) { + side = 'shadow opacity' + amount = hotkeys.shift ? '10%' : '1%' + negative = code === 'ArrowRight' ? 'increase' : 'decrease' + } + else if (hotkeys[metaKey] && (code === 'ArrowUp' || code === 'ArrowDown')) { + side = 'inset' + amount = '' + negative_modifier = '' + negative = code === 'ArrowDown' ? 'set' : 'unset' + } + else if (code === 'ArrowLeft' || code === 'ArrowRight') { + side = hotkeys.alt ? 'shadow spread' : 'shadow x offset' + amount = `${amount}px` + negative = code === 'ArrowRight' ? 'increase' : 'decrease' + } + else if (code === 'ArrowUp' || code === 'ArrowDown') { + side = hotkeys.alt ? 'shadow blur' : 'shadow y offset' + amount = `${amount}px` + negative = code === 'ArrowDown' ? 'increase' : 'decrease' + } + + return { negative, negative_modifier, amount, side } } - render() { + displayCommand({negative, negative_modifier, side, amount}) { + if (negative === `±[${altKey}] `) + negative = '[ ] step shadow, shift+[ ] step radius' + if (negative_modifier === ' to ') + negative_modifier = '' + return ` -
-
- - ${icon} - ${this._tool} Tool - -
-
- coming soon -
-
+ ${negative} + ${side === '[arrow key]' ? '' : side} + ${negative_modifier} + ${amount === 1 ? '' : amount} ` } } diff --git a/app/components/hotkey-map/font.element.js b/app/components/hotkey-map/font.element.js index 730d9b39..979b5b1b 100644 --- a/app/components/hotkey-map/font.element.js +++ b/app/components/hotkey-map/font.element.js @@ -1,15 +1,21 @@ import { HotkeyMap } from './base.element' import { metaKey, altKey } from '../../utilities'; +import { hasScale } from '../../core' export class FontHotkeys extends HotkeyMap { constructor() { super() this._hotkey = 'f' - this._usedkeys = ['shift',metaKey] + this._usedkeys = ['shift',metaKey,altKey] this.tool = 'font' } + /** A scale step where the page has a scale, pixels where it doesn't. */ + step(kind) { + return hasScale(kind) ? 'one scale step' : '1px' + } + createCommand({e:{code}, hotkeys}) { let amount = hotkeys.shift ? 10 : 1 let negative = '[increase/decrease]' @@ -29,7 +35,7 @@ export class FontHotkeys extends HotkeyMap { // leading else if (hotkeys.shift && (code === 'ArrowUp' || code === 'ArrowDown')) { side = 'leading' - amount = '1px' + amount = this.step('lineHeight') if (code === 'ArrowUp') negative = 'increase' @@ -47,10 +53,12 @@ export class FontHotkeys extends HotkeyMap { if (code === 'ArrowDown') negative = 'decrease' } - // font size + // font size — alt is the way back to pixels once a type scale is in play else if (code === 'ArrowUp' || code === 'ArrowDown') { side = 'font size' - amount = '1px' + amount = hotkeys.alt + ? `${hotkeys.shift ? 10 : 1}px` + : this.step('fontSize') if (code === 'ArrowUp') negative = 'increase' diff --git a/app/components/hotkey-map/margin.element.js b/app/components/hotkey-map/margin.element.js index 020bcb55..d6c1ab7f 100644 --- a/app/components/hotkey-map/margin.element.js +++ b/app/components/hotkey-map/margin.element.js @@ -1,5 +1,6 @@ import { HotkeyMap } from './base.element' import { metaKey, altKey } from '../../utilities/' +import { hasScale } from '../../core' export class MarginHotkeys extends HotkeyMap { constructor() { @@ -10,6 +11,14 @@ export class MarginHotkeys extends HotkeyMap { this.tool = 'margin' } + + // bare arrows walk the page's spacing scale when it has one; shift is what + // still counts in pixels + amountFor({hotkeys}) { + return !hotkeys.shift && hasScale('space') + ? 'one scale step' + : super.amountFor({hotkeys}) + } } customElements.define('hotkeys-margin', MarginHotkeys) diff --git a/app/components/hotkey-map/padding.element.js b/app/components/hotkey-map/padding.element.js index 632c5da0..d90b490a 100644 --- a/app/components/hotkey-map/padding.element.js +++ b/app/components/hotkey-map/padding.element.js @@ -1,5 +1,6 @@ import { HotkeyMap } from './base.element' import { metaKey, altKey } from '../../utilities/' +import { hasScale } from '../../core' export class PaddingHotkeys extends HotkeyMap { constructor() { @@ -10,6 +11,14 @@ export class PaddingHotkeys extends HotkeyMap { this.tool = 'padding' } + + // bare arrows walk the page's spacing scale when it has one; shift is what + // still counts in pixels + amountFor({hotkeys}) { + return !hotkeys.shift && hasScale('space') + ? 'one scale step' + : super.amountFor({hotkeys}) + } } customElements.define('hotkeys-padding', PaddingHotkeys) diff --git a/app/components/index.js b/app/components/index.js index f6aeb776..06cf16d0 100644 --- a/app/components/index.js +++ b/app/components/index.js @@ -8,6 +8,7 @@ export { Overlay } from './selection/overlay.element' export { BoxModel } from './selection/box-model.element' export { Corners } from './selection/corners.element' export { Grip } from './selection/grip.element' +export { Insertion } from './selection/insertion.element' export { Metatip } from './metatip/metatip.element' export { Ally } from './metatip/ally.element' diff --git a/app/components/selection/box-model.element.js b/app/components/selection/box-model.element.js index dbeb8c0f..69b7c8ab 100644 --- a/app/components/selection/box-model.element.js +++ b/app/components/selection/box-model.element.js @@ -1,4 +1,5 @@ import { BoxModelStyles } from '../styles.store' +import { labelForLength } from '../../core' export class BoxModel extends HTMLElement { @@ -177,7 +178,10 @@ export class BoxModel extends HTMLElement { createMeasurement(line_model, node_label_id=0) { const measurement = document.createElement('visbug-distance') - measurement.position = { line_model, node_label_id } + measurement.position = { + line_model: { ...line_model, label: labelForLength('space', line_model.d) }, + node_label_id, + } this.$shadow.appendChild(measurement) } } diff --git a/app/components/selection/distance.element.js b/app/components/selection/distance.element.js index 36a68e89..527942ae 100644 --- a/app/components/selection/distance.element.js +++ b/app/components/selection/distance.element.js @@ -47,13 +47,13 @@ export class Distance extends HTMLElement { : '.5 0 1') } - render({q,d}, node_label_id) { + render({q,d,label}, node_label_id) { this.$shadow.host.setAttribute('data-label-id', node_label_id) return `
-
${Math.round(d)}
+
${label || Math.round(d)}
` diff --git a/app/components/selection/insertion.element.css b/app/components/selection/insertion.element.css new file mode 100644 index 00000000..3613c77a --- /dev/null +++ b/app/components/selection/insertion.element.css @@ -0,0 +1,60 @@ +@import "../_variables.css"; + +:host { + position: var(--position, absolute); + /* `auto` on right/bottom is what keeps the popover UA styles + (inset: 0; margin: auto) from centering us in the viewport */ + inset: var(--top) auto auto var(--left); + width: var(--width); + height: var(--height); + margin: 0; + padding: 0; + border: none; + background: transparent; + overflow: visible; + pointer-events: none; + z-index: var(--layer-5); + display: block; + isolation: isolate; +} + +:host::backdrop { + background: none !important; +} + +:host([kind="bar"]) .indicator { + width: 100%; + height: 100%; + border-radius: var(--radius-round, 1e5px); + background: var(--neon-pink, hsl(328 100% 54%)); + box-shadow: 0 0 0 1px hsl(0 0% 100% / 40%), 0 0 8px 0 var(--neon-pink, hsl(328 100% 54%)); +} + +/* pips on the ends read as an insertion caret rather than a border */ +:host([kind="bar"]) .cap { + position: absolute; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--neon-pink, hsl(328 100% 54%)); +} + +:host([kind="bar"][axis="y"]) .cap.start { top: -2px; left: 50%; translate: -50% 0; } +:host([kind="bar"][axis="y"]) .cap.end { bottom: -2px; left: 50%; translate: -50% 0; } +:host([kind="bar"][axis="x"]) .cap.start { left: -2px; top: 50%; translate: 0 -50%; } +:host([kind="bar"][axis="x"]) .cap.end { right: -2px; top: 50%; translate: 0 -50%; } + +:host([kind="cell"]) .indicator, +:host([kind="container"]) .indicator { + width: 100%; + height: 100%; + border-radius: 2px; + outline: 2px dashed var(--neon-purple, hsl(267 100% 58%)); + outline-offset: -2px; + background: hsl(267 100% 58% / 12%); +} + +:host([kind="container"]) .indicator { + outline-style: solid; + background: hsl(267 100% 58% / 6%); +} diff --git a/app/components/selection/insertion.element.js b/app/components/selection/insertion.element.js new file mode 100644 index 00000000..902abda9 --- /dev/null +++ b/app/components/selection/insertion.element.js @@ -0,0 +1,50 @@ +import { InsertionStyles } from '../styles.store' + +/** + * The drop indicator for layout-aware dragging. + * + * Unlike the other selection overlays it isn't anchored to an element — + * an insertion point lives *between* elements, so it takes a raw rect from + * the resolver in app/features/dropzones.js. + * + * kind 'bar' the caret shown between siblings in flex/block flow + * kind 'cell' a grid cell about to receive the element + * kind 'container' the container that would accept the drop + * + * axis 'x' a vertical bar (inserting along a horizontal/row axis) + * axis 'y' a horizontal bar (inserting along a vertical/column axis) + */ +export class Insertion extends HTMLElement { + + constructor() { + super() + this.$shadow = this.attachShadow({ mode: 'closed' }) + } + + connectedCallback() { + this.$shadow.adoptedStyleSheets = [InsertionStyles] + this.setAttribute('popover', 'manual') + this.showPopover && this.showPopover() + } + + disconnectedCallback() { + this.hidePopover && this.hidePopover() + } + + set placement({ rect, kind = 'bar', axis = 'y', isFixed = false }) { + this.setAttribute('kind', kind) + this.setAttribute('axis', axis) + + this.style.setProperty('--position', isFixed ? 'fixed' : 'absolute') + this.style.setProperty('--top', `${rect.top + (isFixed ? 0 : window.scrollY)}px`) + this.style.setProperty('--left', `${rect.left + (isFixed ? 0 : window.scrollX)}px`) + this.style.setProperty('--width', `${rect.width}px`) + this.style.setProperty('--height', `${rect.height}px`) + + this.$shadow.innerHTML = kind === 'bar' + ? `
` + : `
` + } +} + +customElements.define('visbug-insertion', Insertion) diff --git a/app/components/styles.store.js b/app/components/styles.store.js index 89c5f07c..b20b715b 100644 --- a/app/components/styles.store.js +++ b/app/components/styles.store.js @@ -14,6 +14,7 @@ import { default as boxmodel_css } from './selection/box-model.element.css' import { default as metatip_css } from './metatip/metatip.element.css' import { default as hotkeymap_css } from './hotkey-map/base.element.css' import { default as grip_css } from './selection/grip.element.css' +import { default as insertion_css } from './selection/insertion.element.css' import { default as light_css } from './_variables_light.css' import { default as visbug_light_css } from './vis-bug/vis-bug.element_light.css' @@ -44,6 +45,7 @@ export const OverlayStyles = constructStylesheet(overlay_css) export const BoxModelStyles = constructStylesheet(boxmodel_css) export const HotkeymapStyles = constructStylesheet(hotkeymap_css) export const GripStyles = constructStylesheet(grip_css) +export const InsertionStyles = constructStylesheet(insertion_css) export const LightTheme = constructStylesheet(light_css) export const VisBugLightStyles = constructStylesheet(visbug_light_css) diff --git a/app/components/vis-bug/vis-bug.element.css b/app/components/vis-bug/vis-bug.element.css index d2f1ff11..b485123b 100644 --- a/app/components/vis-bug/vis-bug.element.css +++ b/app/components/vis-bug/vis-bug.element.css @@ -192,6 +192,78 @@ } } +/* + 13 tools + 3 colors + the export button overflow shorter viewports — a + 1366x768 screen leaves roughly 660px, which cut the bottom of the toolbar + off entirely. Tighten up rather than let items become unreachable. +*/ +@media (max-height: 820px) { + :host li { + height: 2em; + width: 2em; + margin: 0.02em 0.25em; + } + + :host > ol { + margin: 0.5em 0 0.25em 0.75em; + } + + :host [colors] { + margin-top: 0.25em; + } + + :host [colors] > li { + margin-bottom: 0.25em; + } +} + +@media (max-height: 640px) { + :host li { + height: 1.65em; + width: 1.65em; + } +} + +:host [colors] > #copy-changes { + cursor: pointer; + background-color: var(--theme-bd-2); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); + + &:hover { + background-color: var(--theme-icon_hover-bg); + } + + &:active { + background-color: var(--theme-icon_active-bg); + } + + &:focus-visible { + outline: 2px solid var(--neon-pink); + outline-offset: 2px; + } + + /* the button is the only feedback that a copy happened */ + &[data-state="copied"] { + background-color: var(--neon-pink); + + & > svg { fill: white; } + } + + &[data-state="empty"] > svg, + &[data-state="failed"] > svg { + animation: copy-shake .4s ease; + } + + &[data-state="empty"] > svg { opacity: .4; } + &[data-state="failed"] > svg { fill: var(--neon-pink); } +} + +@keyframes copy-shake { + 25% { transform: translateX(-3px); } + 75% { transform: translateX(3px); } +} + :host [colors] { margin-top: .5em; } diff --git a/app/components/vis-bug/vis-bug.element.js b/app/components/vis-bug/vis-bug.element.js index 82a82cab..7bd77d0f 100644 --- a/app/components/vis-bug/vis-bug.element.js +++ b/app/components/vis-bug/vis-bug.element.js @@ -7,7 +7,7 @@ import { } from '../' import { - Selectable, Moveable, Padding, Margin, EditText, Font, + Selectable, Moveable, Padding, Margin, EditText, exitTextEditing, Font, Flex, Search, ColorPicker, BoxShadow, HueShift, MetaTip, Guides, Screenshot, Position, Accessibility, draggable } from '../../features/' @@ -18,6 +18,7 @@ import { VisBugDarkStyles } from '../styles.store' +import { copyPrompt } from '../../core' import { VisBugModel } from './model' import * as Icons from './vis-bug.icons' import { provideSelectorEngine } from '../../features/search' @@ -121,6 +122,35 @@ export default class VisBug extends HTMLElement { }) ) + const copy_button = this.$shadow.querySelector('#copy-changes') + + const flash = state => { + copy_button.setAttribute('data-state', state) + clearTimeout(this._copy_timer) + this._copy_timer = setTimeout(() => + copy_button.removeAttribute('data-state'), 2000) + } + + const runCopy = async e => { + e.preventDefault() + e.stopPropagation() + + try { + const { copied, count } = await copyPrompt() + flash(copied ? 'copied' : 'empty') + if (copied) console.info(`VisBug: copied changes for ${count} element${count === 1 ? '' : 's'}`) + else console.info('VisBug: nothing has been edited yet') + } + catch (err) { + console.error('VisBug: could not copy changes', err) + flash('failed') + } + } + + copy_button.addEventListener('click', runCopy) + copy_button.addEventListener('keydown', e => + (e.key === 'Enter' || e.key === ' ') && runCopy(e)) + hotkeys(`${metaKey}+/,${metaKey}+.`, e => this.$shadow.host.style.display = this.$shadow.host.style.display === 'none' @@ -172,17 +202,34 @@ export default class VisBug extends HTMLElement {
  1. - + ${Icons.color_text}
  2. - + ${Icons.color_background}
  3. - + ${Icons.color_border}
  4. +
  5. + ${Icons.copy} + +
  6. + +
` } @@ -223,8 +270,13 @@ export default class VisBug extends HTMLElement { text() { this.selectorEngine.onSelectedUpdate(EditText) - this.deactivate_feature = () => + this.deactivate_feature = () => { this.selectorEngine.removeSelectedCallback(EditText) + // Switching tools mid-edit left the caret and contenteditable behind + // on whatever element was being edited, since only the selection + // callback was torn down. + exitTextEditing() + } } align() { diff --git a/app/components/vis-bug/vis-bug.icons.js b/app/components/vis-bug/vis-bug.icons.js index 72a3661d..7d9e58ec 100644 --- a/app/components/vis-bug/vis-bug.icons.js +++ b/app/components/vis-bug/vis-bug.icons.js @@ -131,4 +131,17 @@ export const accessibility = ` -` \ No newline at end of file +` +export const download = ` + + + + +` + +export const copy = ` + + + + +` diff --git a/app/core/changes.js b/app/core/changes.js new file mode 100644 index 00000000..45330ca7 --- /dev/null +++ b/app/core/changes.js @@ -0,0 +1,263 @@ +import { getWin } from './context' +import { getAuthoredStyles, CLASS_PREFIX, ID_ATTR } from './style-store' + +/** + * Remembers what a thing looked like *before* VisBug touched it, so an edit + * can be reported as "was X, now Y" rather than just "Y". + * + * The history journal already records before/after per step, but a step is + * not what you want to hand a developer — nudging padding forty times is + * forty entries and one change. This tracks the original only, and the net + * change is worked out at report time against what's authored right now. + * Anything undone therefore drops out on its own. + */ + +const tracked = new Map() // Element -> { styles: {prop: original}, position, created } + +const entryFor = el => { + if (!tracked.has(el)) + tracked.set(el, { styles: {}, position: undefined, created: false }) + + return tracked.get(el) +} + +const kebab = prop => prop + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + +/** Called before the first write to a property, to capture what was there. */ +export const noteStyle = (el, prop) => { + const entry = entryFor(el) + if (prop in entry.styles) return + + entry.styles[prop] = getWin().getComputedStyle(el).getPropertyValue(kebab(prop)) +} + +/** + * Called on an element's first structural change. `from` is null when the + * node wasn't in the tree yet, which means VisBug created it. + */ +export const notePosition = (el, from) => { + const entry = entryFor(el) + if (entry.position !== undefined) return + + if (!from) { + entry.created = true + entry.position = null + return + } + + entry.position = { + parent: from.parent, + index: [...from.parent.children].indexOf(el), + } +} + +export const forget = () => tracked.clear() + +/* ------------------------------------------------------------ describing --- */ + +const TEST_ATTRS = [ + 'data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa', 'data-e2e', +] + +const IDENTIFYING_ATTRS = [ + 'name', 'type', 'role', 'aria-label', 'alt', 'placeholder', 'title', 'href', 'src', +] + +const isGeneratedClass = name => + name.startsWith(CLASS_PREFIX) + +const stableClasses = el => + [...el.classList].filter(name => !isGeneratedClass(name)) + +const cssEscape = value => + getWin().CSS?.escape ? getWin().CSS.escape(value) : value + +const isUnique = (selector, doc) => { + try { return doc.querySelectorAll(selector).length === 1 } + catch { return false } +} + +/** tag + classes, scoped to the nearest id'd ancestor if that makes it unique */ +const readableSelector = (el, doc) => { + const tag = el.tagName.toLowerCase() + const classes = stableClasses(el).map(c => `.${cssEscape(c)}`).join('') + const own = `${tag}${classes}` + + if (isUnique(own, doc)) return own + + let scope = el.parentElement + while (scope && scope !== doc.body) { + if (scope.id) { + const scoped = `#${cssEscape(scope.id)} ${own}` + if (isUnique(scoped, doc)) return scoped + + // still ambiguous — pin the position, but keep the container visible + const nth = [...scope.children].indexOf(el) + 1 + const pinned = `#${cssEscape(scope.id)} > ${own}:nth-child(${nth})` + if (isUnique(pinned, doc)) return pinned + } + scope = scope.parentElement + } + + const parent = el.parentElement + if (parent && parent !== doc.body) { + const nth = [...parent.children].indexOf(el) + 1 + const withNth = `${own}:nth-child(${nth})` + if (isUnique(withNth, doc)) return withNth + } + + return null +} + +/** readable ancestor trail — says which component owns this element */ +const ancestorTrail = el => { + const parts = [] + let node = el.parentElement + + while (node && node.tagName !== 'BODY' && parts.length < 4) { + const tag = node.tagName.toLowerCase() + const id = node.id ? `#${node.id}` : '' + const classes = stableClasses(node).slice(0, 3).map(c => `.${c}`).join('') + + parts.unshift(`${tag}${id}${classes}`) + node = node.parentElement + } + + return parts.join(' > ') +} + +/** always-unique structural path, the fallback when nothing else identifies it */ +const uniquePath = el => { + const steps = [] + let node = el + + while (node && node.nodeType === 1 && node.tagName !== 'BODY') { + const tag = node.tagName.toLowerCase() + const index = [...node.parentNode.children].indexOf(node) + 1 + steps.unshift(`${tag}:nth-child(${index})`) + node = node.parentElement + } + + return `body > ${steps.join(' > ')}` +} + +// bookkeeping the tools write onto elements, invisible in the source +const EDITOR_ATTRS = [ + ID_ATTR, 'data-selected', 'data-label-id', 'data-selected-hide', + 'data-pseudo-select', 'data-measuring', 'data-outward', 'draggable', + 'visbug-drag-src', 'visbug-drag-container', 'contenteditable', 'spellcheck', +] + +// inline properties the tools set for their own feedback, not as design +const EDITOR_INLINE_PROPS = ['cursor', 'will-change', 'transition', 'opacity'] + +/** + * The opening tag as the source probably writes it — usually the most + * greppable anchor there is, so it has to be free of editor residue. + */ +const openingTag = el => { + const clone = el.cloneNode(false) + + EDITOR_ATTRS.forEach(attr => clone.removeAttribute(attr)) + ;[...clone.classList] + .filter(isGeneratedClass) + .forEach(name => clone.classList.remove(name)) + + if (clone.hasAttribute('style')) { + EDITOR_INLINE_PROPS.forEach(prop => clone.style.removeProperty(prop)) + if (!clone.style.length) clone.removeAttribute('style') + } + + if (clone.getAttribute('class') === '') clone.removeAttribute('class') + + const html = clone.outerHTML + const end = html.indexOf('>') + + return end === -1 ? html : html.slice(0, end + 1) +} + +const textOf = el => { + const text = (el.textContent || '').replace(/\s+/g, ' ').trim() + return text.length > 80 ? `${text.slice(0, 80)}…` : text +} + +/** Several independent ways to point at one element, best first. */ +export const describeElement = el => { + const doc = el.ownerDocument + + const testAttr = TEST_ATTRS + .map(attr => el.hasAttribute(attr) ? `[${attr}="${el.getAttribute(attr)}"]` : null) + .find(Boolean) + + const attributes = IDENTIFYING_ATTRS + .filter(attr => el.hasAttribute(attr)) + .reduce((o, attr) => (o[attr] = el.getAttribute(attr), o), {}) + + return { + tag: el.tagName.toLowerCase(), + id: el.id ? `#${cssEscape(el.id)}` : null, + testAttr, + selector: readableSelector(el, doc), + path: uniquePath(el), + within: ancestorTrail(el), + markup: openingTag(el), + text: textOf(el), + classes: stableClasses(el), + attributes, + } +} + +/* ------------------------------------------------------------ collecting --- */ + +const positionNow = el => ({ + parent: el.parentElement, + index: el.parentElement ? [...el.parentElement.children].indexOf(el) : -1, +}) + +/** + * The net effect of the session: one entry per element that still differs + * from how it started. + */ +export const collectChanges = () => { + const changes = [] + + tracked.forEach((entry, el) => { + const removed = !el.isConnected + + const styles = removed ? [] : Object.entries(getAuthoredStyles(el)) + .map(([prop, after]) => { + // originals were captured under the name the feature used + const before = prop in entry.styles + ? entry.styles[prop] + : entry.styles[Object.keys(entry.styles).find(k => kebab(k) === prop)] + + return { prop, before: before ?? '(not set)', after } + }) + .filter(({ before, after }) => before !== after) + + let moved = null + + if (!removed && entry.position) { + const now = positionNow(el) + if (now.parent !== entry.position.parent || now.index !== entry.position.index) + moved = { from: entry.position, to: now } + } + + if (!styles.length && !moved && !removed && !entry.created) return + + changes.push({ + el, + describe: describeElement(el), + styles, + moved, + removed, + created: entry.created, + }) + }) + + return changes +} + +export const hasChanges = () => collectChanges().length > 0 diff --git a/app/core/changes.test.js b/app/core/changes.test.js new file mode 100644 index 00000000..617e7039 --- /dev/null +++ b/app/core/changes.test.js @@ -0,0 +1,154 @@ +import test from 'ava' + +import { setupFixtureTab, teardownPptrTab, changeMode, pointIn, dragFromTo } from '../../tests/helpers' + +test.beforeEach(async t => { + await setupFixtureTab(t, 'changes.html') + + // keep the prompt out of the real clipboard so tests can read it + await t.context.page.evaluate(() => { + window.__copied = null + Object.defineProperty(navigator.clipboard, 'writeText', { + configurable: true, + value: async text => { window.__copied = text }, + }) + }) +}) + +const clickCopy = async page => { + await page.evaluate(() => { + window.__copied = null + document.querySelector('vis-bug').$shadow.querySelector('#copy-changes').click() + }) + + await page.waitForTimeout(400) + + return page.evaluate(() => ({ + text: window.__copied, + state: document.querySelector('vis-bug').$shadow + .querySelector('#copy-changes').getAttribute('data-state'), + })) +} + +const nudgePadding = async (page, times = 4) => { + await changeMode({ tool: 'padding', page }) + await (await page.$('[data-testid="hero-cta"]')).click() + + for (let i = 0; i < times; i++) + await page.keyboard.press('ArrowUp') + + await page.waitForTimeout(600) +} + +test('copies nothing when nothing has been edited', async t => { + const { text, state } = await clickCopy(t.context.page) + + t.is(text, null) + t.is(state, 'empty') +}) + +test('a style edit is reported with before and after values', async t => { + const { page } = t.context + await nudgePadding(page) + + const { text, state } = await clickCopy(page) + + t.is(state, 'copied') + t.regex(text, /\*\*Elements changed:\*\* 1/) + t.regex(text, /\| `padding-top` \| `8px` \| `12px` \|/) +}) + +test('an edited element carries several independent anchors', async t => { + const { page } = t.context + await nudgePadding(page) + + const { text } = await clickCopy(page) + + t.regex(text, /test attribute: `\[data-testid="hero-cta"\]`/) + t.regex(text, /css selector: `button\.cta\.primary`/) + t.regex(text, /opening tag: `