diff --git a/app/components/index.js b/app/components/index.js index b951c6d6..3734bf47 100644 --- a/app/components/index.js +++ b/app/components/index.js @@ -14,6 +14,7 @@ export { BoxModel } from './selection/box-model.element' export { Corners } from './selection/corners.element' export { Grip } from './selection/grip.element' export { Rotation } from './selection/rotation.element' +export { ProjectiveTransform } from './selection/projective-transform.element' export { SelectionActions } from './selection/actions.element' export { Metatip } from './metatip/metatip.element' diff --git a/app/components/selection/actions.test.js b/app/components/selection/actions.test.js index 75416b82..e19a2fb7 100644 --- a/app/components/selection/actions.test.js +++ b/app/components/selection/actions.test.js @@ -19,7 +19,7 @@ test('Should show all export formats in the selection actions menu', async t => group: shadow.querySelector('.group').firstChild.textContent, rootOpen: shadow.querySelector('.menu').matches(':popover-open'), submenuOpen: shadow.querySelector('.submenu-items').matches(':popover-open'), - formats: Array.from(shadow.querySelectorAll('[data-command]')) + formats: Array.from(shadow.querySelectorAll('.submenu-items [data-command]')) .map(button => button.textContent), } }) @@ -43,10 +43,12 @@ test('Should hide when no contributing plug-in is active', async t => { const hidden = await page.evaluate(() => { const visbug = document.querySelector('vis-bug') visbug.setPluginActive('export', false) + visbug.setPluginActive('projective-transform', false) const handles = document.querySelector('visbug-handles') const result = handles.$shadow .querySelector('visbug-selection-actions').hidden visbug.setPluginActive('export', true) + visbug.setPluginActive('projective-transform', true) return result }) diff --git a/app/components/selection/box-model.element.js b/app/components/selection/box-model.element.js index 89182149..ecf32943 100644 --- a/app/components/selection/box-model.element.js +++ b/app/components/selection/box-model.element.js @@ -16,6 +16,12 @@ export class BoxModel extends HTMLElement { disconnectedCallback() {} set position(payload) { + if (!payload) { + this.drawable = {} + this.$shadow.innerHTML = '' + return + } + this.$shadow.innerHTML = this.render(payload) this.createMeasurements({...payload, ...this.drawable.measurementQuads}) } diff --git a/app/components/selection/handles.element.js b/app/components/selection/handles.element.js index fd1ff098..0edb82b6 100644 --- a/app/components/selection/handles.element.js +++ b/app/components/selection/handles.element.js @@ -44,6 +44,7 @@ export class Handles extends HTMLElement { set position({el, node_label_id}) { this.source_el = el + const backdrop = this.$shadow.querySelector('visbug-boxmodel') this.$shadow.innerHTML = this.render(getBoxQuad(el), node_label_id, isFixed(el)) const actions = this.$shadow.querySelector('visbug-selection-actions') @@ -51,7 +52,7 @@ export class Handles extends HTMLElement { if (this._backdrop) { this.backdrop = { - element: this._backdrop.update(el), + element: this._backdrop.update(el, false, backdrop), update: this._backdrop.update, } } diff --git a/app/components/selection/projective-transform.element.css b/app/components/selection/projective-transform.element.css new file mode 100644 index 00000000..60e5083b --- /dev/null +++ b/app/components/selection/projective-transform.element.css @@ -0,0 +1,54 @@ +@import "../_variables.css"; + +:host { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; + box-sizing: border-box; + margin: 0; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; + pointer-events: none; + z-index: var(--layer-3); +} + +:host::backdrop { + background: none !important; +} + +svg { + display: block; + width: 100%; + height: 100%; + overflow: hidden; +} + +.outline { + fill: none; + stroke: var(--neon-pink); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.handle { + fill: white; + stroke: var(--neon-pink); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + pointer-events: auto; + cursor: move; +} + +.cross { + stroke: var(--neon-pink); + stroke-width: 1.5; + stroke-linecap: round; + vector-effect: non-scaling-stroke; + pointer-events: none; +} diff --git a/app/components/selection/projective-transform.element.js b/app/components/selection/projective-transform.element.js new file mode 100644 index 00000000..c1bb0446 --- /dev/null +++ b/app/components/selection/projective-transform.element.js @@ -0,0 +1,421 @@ +import { ProjectiveTransformStyles } from '../styles.store' +import { StyleChange } from '../../features/history' +import { getBoxQuad, quadPath } from './quad' +import { + parseProjectiveTransform, + pointsApproximatelyEqual, + projectiveMatrixValues, + serializeMatrix3d, +} from './projective-transform' + +const HANDLE_RADIUS = 7 +const CROSS_RADIUS = 3.5 + +const quadPoints = quad => [quad.p1, quad.p2, quad.p3, quad.p4] + +const finitePoint = point => + Number.isFinite(point?.x) && Number.isFinite(point?.y) + +export class ProjectiveTransform extends HTMLElement { + constructor() { + super() + this.$shadow = this.attachShadow({mode: 'closed'}) + this.styles = [ProjectiveTransformStyles] + this.position_frame = null + this.pointer_id = undefined + this.on_pointer_down = this.on_pointer_down.bind(this) + this.on_pointer_move = this.on_pointer_move.bind(this) + this.on_pointer_up = this.on_pointer_up.bind(this) + this.on_position_change = this.on_position_change.bind(this) + this.on_document_click = this.on_document_click.bind(this) + this.on_history_change = this.on_history_change.bind(this) + this.on_tool_change = this.on_tool_change.bind(this) + this.on_transition_run = this.on_transition_run.bind(this) + this.on_transition_end = this.on_transition_end.bind(this) + this.refresh_transition = this.refresh_transition.bind(this) + } + + connectedCallback() { + this.$shadow.adoptedStyleSheets = this.styles + this.$shadow.innerHTML = this.render() + this.svg = this.$shadow.querySelector('svg') + this.path = this.$shadow.querySelector('.outline') + this.svg.addEventListener('pointerdown', this.on_pointer_down) + window.addEventListener('resize', this.on_position_change) + window.addEventListener('scroll', this.on_position_change, true) + this.outside_click_timer = setTimeout(() => { + if (this.isConnected) + document.addEventListener('click', this.on_document_click, true) + }) + document.addEventListener('visbug-tool-change', this.on_tool_change) + + this.source_observer = new MutationObserver(this.on_position_change) + this.observe_source() + this.stop_history_refresh = document.querySelector('vis-bug')?.history + ?.subscribe(this.on_history_change) + + this.setAttribute('popover', 'manual') + this.showPopover && this.showPopover() + this.update_position() + } + + disconnectedCallback() { + this.hidePopover && this.hidePopover() + this.svg?.removeEventListener('pointerdown', this.on_pointer_down) + window.removeEventListener('resize', this.on_position_change) + window.removeEventListener('scroll', this.on_position_change, true) + clearTimeout(this.outside_click_timer) + document.removeEventListener('click', this.on_document_click, true) + document.removeEventListener('visbug-tool-change', this.on_tool_change) + this.source_observer?.disconnect() + this.source_el?.removeEventListener('transitionrun', this.on_transition_run) + this.source_el?.removeEventListener('transitionend', this.on_transition_end) + this.source_el?.removeEventListener('transitioncancel', this.on_transition_end) + this.stop_history_refresh?.() + this.position_frame && cancelAnimationFrame(this.position_frame) + this.history_frame && cancelAnimationFrame(this.history_frame) + this.transition_frame && cancelAnimationFrame(this.transition_frame) + this.restore_suppressed_overlays() + this.stop_drag() + } + + set source(element) { + this.source_observer?.disconnect() + this.source_el?.removeEventListener('transitionrun', this.on_transition_run) + this.source_el?.removeEventListener('transitionend', this.on_transition_end) + this.source_el?.removeEventListener('transitioncancel', this.on_transition_end) + this.source_el = element + + if (this.isConnected && element) { + this.observe_source() + this.update_position() + } + } + + get source() { + return this.source_el + } + + suppress_overlays(overlays) { + this.restore_suppressed_overlays() + this.suppressed_overlays = overlays.map(element => ({ + element, + display: element.style.getPropertyValue('display'), + priority: element.style.getPropertyPriority('display'), + })) + this.suppressed_overlays.forEach(({element}) => { + element.setAttribute('data-projective-suppressed', '') + element.style.setProperty('display', 'none', 'important') + }) + } + + restore_suppressed_overlays() { + this.suppressed_overlays?.forEach(({element, display, priority}) => { + if (!element.isConnected) return + + element.removeAttribute('data-projective-suppressed') + display + ? element.style.setProperty('display', display, priority) + : element.style.removeProperty('display') + }) + this.suppressed_overlays = null + } + + observe_source() { + if (!this.source_el) return + + this.source_observer.observe(this.source_el, { + attributes: true, + attributeFilter: ['class', 'style', 'data-selected'], + }) + this.source_el.addEventListener('transitionrun', this.on_transition_run) + this.source_el.addEventListener('transitionend', this.on_transition_end) + this.source_el.addEventListener('transitioncancel', this.on_transition_end) + } + + on_position_change() { + if (!this.source_el?.isConnected || !this.source_el.hasAttribute('data-selected')) { + this.remove() + return + } + if (this.position_frame || this.pointer_id !== undefined) return + + this.position_frame = requestAnimationFrame(() => { + this.position_frame = null + this.update_position() + }) + } + + on_document_click(event) { + if (!event.composedPath().includes(this)) this.remove() + } + + on_tool_change() { + this.remove() + } + + on_history_change() { + if (!this.isConnected) return + + this.update_position() + this.history_refreshes = 2 + if (!this.history_frame) + this.history_frame = requestAnimationFrame(() => this.refresh_history()) + } + + refresh_history() { + this.history_frame = null + this.update_position() + if (this.history_refreshes-- > 0) + this.history_frame = requestAnimationFrame(() => this.refresh_history()) + } + + on_transition_run(event) { + if (event.target !== this.source_el || event.propertyName !== 'transform') return + if (!this.transition_frame) + this.transition_frame = requestAnimationFrame(this.refresh_transition) + } + + on_transition_end(event) { + if (event.target !== this.source_el || event.propertyName !== 'transform') return + if (this.transition_frame) cancelAnimationFrame(this.transition_frame) + this.transition_frame = null + this.update_position() + } + + refresh_transition() { + this.transition_frame = null + if (!this.isConnected || this.pointer_id !== undefined) return + + this.update_position() + this.transition_frame = requestAnimationFrame(this.refresh_transition) + } + + update_position() { + if (!this.path || !this.source_el?.isConnected) return + + const points = quadPoints(getBoxQuad(this.source_el)) + if (points.some(point => !finitePoint(point))) { + this.remove() + return + } + + this.path.setAttribute('d', quadPath({ + p1: points[0], p2: points[1], p3: points[2], p4: points[3], + })) + + points.forEach((point, index) => { + const group = this.$shadow.querySelector(`[data-corner="${index}"]`) + group.setAttribute('transform', `translate(${point.x} ${point.y})`) + }) + } + + on_pointer_down(event) { + const handle = event.target.closest('.handle') + if (!handle || event.button !== 0 || !this.start_drag()) return + + event.preventDefault() + event.stopPropagation() + this.active_corner = Number(handle.dataset.corner) + this.pointer_id = event.pointerId + this.drag_handle = handle + this.original_cursor = document.body.style.cursor + this.original_user_select = document.body.style.userSelect + document.body.style.cursor = 'move' + document.body.style.userSelect = 'none' + handle.setPointerCapture(event.pointerId) + handle.addEventListener('pointermove', this.on_pointer_move) + handle.addEventListener('pointerup', this.on_pointer_up) + handle.addEventListener('pointercancel', this.on_pointer_up) + } + + start_drag() { + if (!this.source_el?.isConnected) return false + + this.original_transform = this.source_el.style.transform + this.original_priority = this.source_el.style.getPropertyPriority('transform') + this.original_transition = this.source_el.style.transition + const persistedTransform = this.original_transform + || getComputedStyle(this.source_el).transform + const parsed = parseProjectiveTransform(persistedTransform) + this.base_transform = parsed.baseTransform + this.projective_transform = parsed.projectiveTransform + const currentQuad = getBoxQuad(this.source_el) + + this.source_el.style.transition = 'none' + this.source_el.style.transform = this.build_transform(this.base_transform, '') + this.target_points = quadPoints(currentQuad) + .map(point => this.viewport_to_local(point)) + this.source_el.style.transform = this.build_transform( + this.base_transform, + this.projective_transform, + ) + + if (this.target_points.length !== 4 + || this.target_points.some(point => !finitePoint(point))) { + this.source_el.style.transform = this.original_transform + this.source_el.style.transition = this.original_transition + return false + } + + return true + } + + on_pointer_move(event) { + if (event.pointerId !== this.pointer_id) return + + event.preventDefault() + event.stopPropagation() + const point = this.local_pointer_point(event) + if (!point) return + + this.target_points[this.active_corner] = point + this.apply_preview() + } + + on_pointer_up(event) { + if (event.pointerId !== this.pointer_id) return + + event.preventDefault() + event.stopPropagation() + const point = this.local_pointer_point(event) + if (point) { + this.target_points[this.active_corner] = point + this.apply_preview() + } + + const newTransform = this.source_el.style.transform + this.stop_drag() + + if (this.original_transform !== newTransform) { + document.querySelector('vis-bug')?.history?.push(new StyleChange({ + element: this.source_el, + property: 'transform', + oldValue: this.original_transform, + newValue: newTransform, + oldPriority: this.original_priority, + newPriority: this.source_el.style.getPropertyPriority('transform'), + })) + } + + this.update_position() + } + + stop_drag() { + if (this.drag_handle) { + this.drag_handle.removeEventListener('pointermove', this.on_pointer_move) + this.drag_handle.removeEventListener('pointerup', this.on_pointer_up) + this.drag_handle.removeEventListener('pointercancel', this.on_pointer_up) + } + if (this.original_transition !== undefined && this.source_el) + this.source_el.style.transition = this.original_transition + if (this.original_cursor !== undefined) + document.body.style.cursor = this.original_cursor + if (this.original_user_select !== undefined) + document.body.style.userSelect = this.original_user_select + + this.pointer_id = undefined + this.active_corner = undefined + this.drag_handle = null + this.original_transition = undefined + this.original_cursor = undefined + this.original_user_select = undefined + } + + local_pointer_point(event) { + const previewTransform = this.source_el.style.transform + this.source_el.style.transform = this.build_transform(this.base_transform, '') + const point = this.viewport_to_local({x: event.clientX, y: event.clientY}) + this.source_el.style.transform = previewTransform + return finitePoint(point) ? {x: point.x, y: point.y} : null + } + + viewport_to_local(point) { + return this.source_el.convertPointFromNode({ + x: point.x + window.scrollX, + y: point.y + window.scrollY, + }, document.documentElement) + } + + apply_preview() { + const projectiveTransform = this.build_projective_transform(this.target_points) + if (projectiveTransform == null) return + + this.projective_transform = projectiveTransform + this.source_el.style.transform = this.build_transform( + this.base_transform, + projectiveTransform, + ) + this.update_position() + } + + build_projective_transform(points) { + const {width, height} = this.element_size() + const sourcePoints = [ + {x: 0, y: 0}, + {x: width, y: 0}, + {x: width, y: height}, + {x: 0, y: height}, + ] + + if (!width || !height || points.length !== 4) return '' + if (pointsApproximatelyEqual(points, sourcePoints)) return '' + + const values = projectiveMatrixValues(points, width, height) + if (!values) return null + + const matrix = new DOMMatrix(values) + const [originX, originY] = getComputedStyle(this.source_el) + .transformOrigin.split(' ') + .map(value => parseFloat(value) || 0) + const corrected = new DOMMatrix() + .translate(-originX, -originY) + .multiply(matrix) + .multiply(new DOMMatrix().translate(originX, originY)) + + return serializeMatrix3d(corrected) + } + + element_size() { + const style = getComputedStyle(this.source_el) + return { + width: this.source_el.offsetWidth + || parseFloat(style.width) + || this.source_el.getBBox?.().width + || 0, + height: this.source_el.offsetHeight + || parseFloat(style.height) + || this.source_el.getBBox?.().height + || 0, + } + } + + build_transform(baseTransform, projectiveTransform) { + return [baseTransform, projectiveTransform] + .map(transform => transform?.trim()) + .filter(Boolean) + .join(' ') + } + + render() { + return ` + + + ${[0, 1, 2, 3].map(index => ` + + + + + `).join('')} + + ` + } +} + +customElements.define('visbug-projective-transform', ProjectiveTransform) diff --git a/app/components/selection/projective-transform.js b/app/components/selection/projective-transform.js new file mode 100644 index 00000000..efb597a5 --- /dev/null +++ b/app/components/selection/projective-transform.js @@ -0,0 +1,122 @@ +const EPSILON = 1e-8 + +export const splitTransformFunctions = transform => { + const parts = [] + let startIndex = -1 + let depth = 0 + + for (let index = 0; index < transform.length; index++) { + const character = transform[index] + + if (character === '(') { + depth++ + } + else if (character === ')') { + depth-- + if (depth === 0 && startIndex !== -1) { + parts.push(transform.slice(startIndex, index + 1).trim()) + startIndex = -1 + } + } + else if (depth === 0 && startIndex === -1 && character.trim()) { + startIndex = index + } + } + + if (!parts.length && transform.trim()) parts.push(transform.trim()) + return parts +} + +export const parseProjectiveTransform = transform => { + if (!transform || transform === 'none') + return {baseTransform: '', projectiveTransform: ''} + + const parts = splitTransformFunctions(transform) + const lastPart = parts.at(-1) || '' + const functionName = lastPart + .slice(0, lastPart.indexOf('(')) + .trim() + .toLowerCase() + + if (functionName === 'matrix' || functionName === 'matrix3d') { + return { + baseTransform: parts.slice(0, -1).join(' ').trim(), + projectiveTransform: lastPart, + } + } + + return {baseTransform: transform.trim(), projectiveTransform: ''} +} + +export const projectiveMatrixValues = (points, width, height) => { + if (!width || !height || points.length !== 4) return null + + const [p1, p2, p3, p4] = points + const dx1 = p2.x - p3.x + const dx2 = p4.x - p3.x + const dx3 = p1.x - p2.x + p3.x - p4.x + const dy1 = p2.y - p3.y + const dy2 = p4.y - p3.y + const dy3 = p1.y - p2.y + p3.y - p4.y + let aUnit + let bUnit + let dUnit + let eUnit + let gUnit + let hUnit + + if (Math.abs(dx3) < EPSILON && Math.abs(dy3) < EPSILON) { + aUnit = p2.x - p1.x + bUnit = p4.x - p1.x + dUnit = p2.y - p1.y + eUnit = p4.y - p1.y + gUnit = 0 + hUnit = 0 + } + else { + const determinant = dx1 * dy2 - dx2 * dy1 + if (Math.abs(determinant) < EPSILON) return null + + gUnit = (dx3 * dy2 - dx2 * dy3) / determinant + hUnit = (dx1 * dy3 - dx3 * dy1) / determinant + aUnit = p2.x - p1.x + gUnit * p2.x + bUnit = p4.x - p1.x + hUnit * p4.x + dUnit = p2.y - p1.y + gUnit * p2.y + eUnit = p4.y - p1.y + hUnit * p4.y + } + + const values = [ + aUnit / width, dUnit / width, 0, gUnit / width, + bUnit / height, eUnit / height, 0, hUnit / height, + 0, 0, 1, 0, + p1.x, p1.y, 0, 1, + ] + + return values.every(Number.isFinite) ? values : null +} + +export const formatMatrixNumber = value => { + if (Math.abs(value) < 1e-10) return '0' + + const rounded = value.toFixed(8).replace(/0+$/, '').replace(/\.$/, '') + return rounded === '-0' ? '0' : rounded +} + +export const serializeMatrix3d = matrix => { + const values = [ + matrix.m11, matrix.m12, matrix.m13, matrix.m14, + matrix.m21, matrix.m22, matrix.m23, matrix.m24, + matrix.m31, matrix.m32, matrix.m33, matrix.m34, + matrix.m41, matrix.m42, matrix.m43, matrix.m44, + ] + + return `matrix3d(${values.map(formatMatrixNumber).join(', ')})` +} + +export const pointsApproximatelyEqual = (pointsA, pointsB) => + pointsA.length === pointsB.length + && pointsA.every((point, index) => { + const compareTo = pointsB[index] + return Math.abs(point.x - compareTo.x) < .01 + && Math.abs(point.y - compareTo.y) < .01 + }) diff --git a/app/components/selection/projective-transform.test.js b/app/components/selection/projective-transform.test.js new file mode 100644 index 00000000..2adcae04 --- /dev/null +++ b/app/components/selection/projective-transform.test.js @@ -0,0 +1,56 @@ +import test from 'ava' +import { + parseProjectiveTransform, + projectiveMatrixValues, + splitTransformFunctions, +} from './projective-transform.js' + +test('splits and identifies a trailing projective matrix', t => { + const transform = 'translate(2px, 4px) rotate(10deg) matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3, 4, 0, 1)' + + t.deepEqual(splitTransformFunctions(transform), [ + 'translate(2px, 4px)', + 'rotate(10deg)', + 'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3, 4, 0, 1)', + ]) + t.deepEqual(parseProjectiveTransform(transform), { + baseTransform: 'translate(2px, 4px) rotate(10deg)', + projectiveTransform: 'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3, 4, 0, 1)', + }) +}) + +test('preserves non-matrix transforms as the base transform', t => { + t.deepEqual(parseProjectiveTransform('rotate(12deg)'), { + baseTransform: 'rotate(12deg)', + projectiveTransform: '', + }) +}) + +test('creates an identity matrix for an unchanged rectangle', t => { + const values = projectiveMatrixValues([ + {x: 0, y: 0}, + {x: 100, y: 0}, + {x: 100, y: 50}, + {x: 0, y: 50}, + ], 100, 50) + + t.deepEqual(values, [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]) +}) + +test('creates perspective terms when one corner moves', t => { + const values = projectiveMatrixValues([ + {x: 15, y: 10}, + {x: 100, y: 0}, + {x: 100, y: 50}, + {x: 0, y: 50}, + ], 100, 50) + + t.truthy(values) + t.true(values.every(Number.isFinite)) + t.true(Math.abs(values[3]) > 0 || Math.abs(values[7]) > 0) +}) diff --git a/app/components/styles.store.js b/app/components/styles.store.js index 7dc2c693..f6f1fcf8 100644 --- a/app/components/styles.store.js +++ b/app/components/styles.store.js @@ -15,6 +15,7 @@ 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 rotation_css } from './selection/rotation.element.css' +import { default as projectiveTransform_css } from './selection/projective-transform.element.css' import { default as actions_css } from './selection/actions.element.css' import { default as light_css } from './_variables_light.css' @@ -47,6 +48,7 @@ export const BoxModelStyles = constructStylesheet(boxmodel_css) export const HotkeymapStyles = constructStylesheet(hotkeymap_css) export const GripStyles = constructStylesheet(grip_css) export const RotationStyles = constructStylesheet(rotation_css) +export const ProjectiveTransformStyles = constructStylesheet(projectiveTransform_css) export const ActionsStyles = constructStylesheet(actions_css) export const LightTheme = constructStylesheet(light_css) diff --git a/app/components/vis-bug/vis-bug.element.js b/app/components/vis-bug/vis-bug.element.js index ad201d57..90d93e98 100644 --- a/app/components/vis-bug/vis-bug.element.js +++ b/app/components/vis-bug/vis-bug.element.js @@ -190,6 +190,9 @@ export default class VisBug extends HTMLElement { el.attr('data-active', true) this.active_tool = el + document.dispatchEvent(new CustomEvent('visbug-tool-change', { + detail: {tool: el.dataset.tool}, + })) this[el.dataset.tool]() } diff --git a/app/features/margin.js b/app/features/margin.js index 34292275..db0ceb10 100644 --- a/app/features/margin.js +++ b/app/features/margin.js @@ -1,5 +1,5 @@ import hotkeys from 'hotkeys-js' -import { metaKey, getStyle, getSide, showHideSelected } from '../utilities/' +import { metaKey, getStyle, getSide } from '../utilities/' import { recordStyleChanges } from './history' const key_events = 'up,down,left,right' @@ -36,7 +36,6 @@ export function Margin(visbug, history) { } const updateMargin = (els, direction) => els - .map(el => showHideSelected(el)) .map(el => ({ el, style: 'margin' + getSide(direction), @@ -104,33 +103,37 @@ function removeBackgrounds(els) { }) } -export function createMarginVisual(el, hover = false) { +export function createMarginVisual( + el, hover = false, boxdisplay = document.createElement('visbug-boxmodel') +) { const bounds = el.getBoundingClientRect() const calculatedStyle = getStyle(el, 'margin') - const boxdisplay = document.createElement('visbug-boxmodel') - - if (calculatedStyle !== '0px') { - const sides = { - top: getStyle(el, 'marginTop'), - right: getStyle(el, 'marginRight'), - bottom: getStyle(el, 'marginBottom'), - left: getStyle(el, 'marginLeft'), - } - - Object.entries(sides).forEach(([side, val]) => { - if (typeof val !== 'number') - val = parseInt(getStyle(el, 'margin'+'-'+side).slice(0, -2)) - - sides[side] = Math.round(val.toFixed(1) * 100) / 100 - }) - - boxdisplay.position = { - mode: 'margin', - color: hover ? 'purple' : 'pink', - bounds, - sides, - element: el - } + + if (calculatedStyle === '0px') { + boxdisplay.position = null + return boxdisplay + } + + const sides = { + top: getStyle(el, 'marginTop'), + right: getStyle(el, 'marginRight'), + bottom: getStyle(el, 'marginBottom'), + left: getStyle(el, 'marginLeft'), + } + + Object.entries(sides).forEach(([side, val]) => { + if (typeof val !== 'number') + val = parseInt(getStyle(el, 'margin'+'-'+side).slice(0, -2)) + + sides[side] = Math.round(val.toFixed(1) * 100) / 100 + }) + + boxdisplay.position = { + mode: 'margin', + color: hover ? 'purple' : 'pink', + bounds, + sides, + element: el } return boxdisplay diff --git a/app/features/margin.test.js b/app/features/margin.test.js index a52ad263..8cb43127 100644 --- a/app/features/margin.test.js +++ b/app/features/margin.test.js @@ -81,4 +81,25 @@ test('Can change values by 10 with shift key', async t => { t.pass() }) +test('Keeps the box-model overlay visible and updates it in place', async t => { + const { page } = t.context + + await page.click(test_selector) + await page.evaluate(() => { + const handles = document.querySelector('visbug-handles') + window.marginBoxModel = handles.$shadow.querySelector('visbug-boxmodel') + }) + + for (let i = 0; i < 3; i++) { + await page.keyboard.press('ArrowUp') + const overlay = await page.$eval('visbug-handles', handles => ({ + visible: handles.style.display !== 'none', + connected: window.marginBoxModel.isConnected, + sameNode: handles.$shadow.querySelector('visbug-boxmodel') === window.marginBoxModel, + })) + + t.deepEqual(overlay, {visible: true, connected: true, sameNode: true}) + } +}) + test.afterEach.always(teardownPptrTab) diff --git a/app/features/padding.js b/app/features/padding.js index 8285122d..2f18d494 100644 --- a/app/features/padding.js +++ b/app/features/padding.js @@ -1,5 +1,5 @@ import hotkeys from 'hotkeys-js' -import { metaKey, getStyle, getSide, showHideSelected, expandBorders } from '../utilities/' +import { metaKey, getStyle, getSide, expandBorders } from '../utilities/' import { recordStyleChanges } from './history' const key_events = 'up,down,left,right' @@ -36,7 +36,6 @@ export function Padding(visbug, history) { } const updatePadding = (els, direction) => els - .map(el => showHideSelected(el)) .map(el => ({ el, style: 'padding' + getSide(direction), @@ -104,37 +103,41 @@ function removeBackgrounds(els) { }) } -export function createPaddingVisual(el, hover = false) { +export function createPaddingVisual( + el, hover = false, boxdisplay = document.createElement('visbug-boxmodel') +) { const bounds = el.getBoundingClientRect() const calculatedStyle = getStyle(el, 'padding') const calculatedBorder = expandBorders(getStyle(el, 'border-width')) - const boxdisplay = document.createElement('visbug-boxmodel') - - if (calculatedStyle !== '0px') { - const sides = { - top: getStyle(el, 'paddingTop'), - right: getStyle(el, 'paddingRight'), - bottom: getStyle(el, 'paddingBottom'), - left: getStyle(el, 'paddingLeft'), - } - - Object.entries(sides).forEach(([side, val]) => { - if (typeof val !== 'number') - val = parseInt(getStyle(el, 'padding'+'-'+side).slice(0, -2)) - - sides[side] = Math.round(val.toFixed(1) * 100) / 100 - }) - - boxdisplay.position = { - mode: 'padding', - color: hover ? 'purple' : 'pink', - bounds, - sides: { - ...sides, - borders: calculatedBorder, - }, - element: el - } + + if (calculatedStyle === '0px') { + boxdisplay.position = null + return boxdisplay + } + + const sides = { + top: getStyle(el, 'paddingTop'), + right: getStyle(el, 'paddingRight'), + bottom: getStyle(el, 'paddingBottom'), + left: getStyle(el, 'paddingLeft'), + } + + Object.entries(sides).forEach(([side, val]) => { + if (typeof val !== 'number') + val = parseInt(getStyle(el, 'padding'+'-'+side).slice(0, -2)) + + sides[side] = Math.round(val.toFixed(1) * 100) / 100 + }) + + boxdisplay.position = { + mode: 'padding', + color: hover ? 'purple' : 'pink', + bounds, + sides: { + ...sides, + borders: calculatedBorder, + }, + element: el } return boxdisplay diff --git a/app/features/padding.test.js b/app/features/padding.test.js index 4fb7e932..a2ca8d41 100644 --- a/app/features/padding.test.js +++ b/app/features/padding.test.js @@ -81,4 +81,25 @@ test('Can change values by 10 with shift key', async t => { t.pass() }) +test('Keeps the box-model overlay visible and updates it in place', async t => { + const { page } = t.context + + await page.click(test_selector) + await page.evaluate(() => { + const handles = document.querySelector('visbug-handles') + window.paddingBoxModel = handles.$shadow.querySelector('visbug-boxmodel') + }) + + for (let i = 0; i < 3; i++) { + await page.keyboard.press('ArrowUp') + const overlay = await page.$eval('visbug-handles', handles => ({ + visible: handles.style.display !== 'none', + connected: window.paddingBoxModel.isConnected, + sameNode: handles.$shadow.querySelector('visbug-boxmodel') === window.paddingBoxModel, + })) + + t.deepEqual(overlay, {visible: true, connected: true, sameNode: true}) + } +}) + test.afterEach.always(teardownPptrTab) diff --git a/app/features/selectable.js b/app/features/selectable.js index 10742c7f..b38042ef 100644 --- a/app/features/selectable.js +++ b/app/features/selectable.js @@ -172,7 +172,8 @@ export function Selectable(visbug, history) { document.onkeyup = function(e) { if (did_hide) { $('visbug-handles, visbug-label, visbug-hover, visbug-grip, visbug-rotation').forEach(el => - el.style.display = null) + !el.hasAttribute('data-projective-suppressed') + && (el.style.display = null)) did_hide = false } diff --git a/app/plugins/_registry.js b/app/plugins/_registry.js index 497b6743..99548bee 100644 --- a/app/plugins/_registry.js +++ b/app/plugins/_registry.js @@ -23,6 +23,12 @@ import { selectionActions as export_selection_actions, default as ExportPlugin } from './export' +import { + commands as projective_transform_commands, + description as projective_transform_description, + selectionActions as projective_transform_selection_actions, + default as ProjectiveTransformPlugin +} from './projective-transform' export const PluginRegistry = new Map() export const SelectionActionRegistry = new Map() @@ -143,6 +149,13 @@ const builtInPlugins = [ {id: 'loop-through-widths', commands: loop_thru_widths_commands, execute: LoopThruWidths}, // ...commandsToHash(placeholdifier_commands, PlaceholdifierPlugin), {id: 'expand-text', commands: expand_text_commands, execute: ExpandTextPlugin}, + { + id: 'projective-transform', + commands: projective_transform_commands, + execute: ProjectiveTransformPlugin, + selectionActions: projective_transform_selection_actions, + active: true, + }, { id: 'export', commands: export_commands, @@ -174,6 +187,7 @@ export const PluginHints = [ {command: loop_thru_widths_commands[0], description: loop_thru_widths_description}, // {command: placeholdifier_commands[0], description: placeholdifier_description}, {command: expand_text_commands[0], description: expand_text_description}, + {command: projective_transform_commands[0], description: projective_transform_description}, // ...colorblind_commands.map(cbc => { // return { // command: cbc, description: `simulate ${cbc}` diff --git a/app/plugins/projective-transform.js b/app/plugins/projective-transform.js new file mode 100644 index 00000000..8043d8ac --- /dev/null +++ b/app/plugins/projective-transform.js @@ -0,0 +1,33 @@ +export const description = 'projectively transform a selected element by moving its corners' +export const commands = ['3d-transform'] +export const selectionActions = [{ + id: '3d-transform', + label: '3D transform', + command: '3d-transform', + order: 90, +}] + +const selectionOverlays = [ + 'visbug-handles', + 'visbug-label', + 'visbug-hover', + 'visbug-distance', + 'visbug-rotation', + 'visbug-grip', + 'visbug-corners', +].join(',') + +export default function projectiveTransform({selected, source}) { + const element = source || selected[0] + if (!element) throw new Error('Select an element before using 3D transform') + + document.querySelectorAll('visbug-projective-transform') + .forEach(overlay => overlay.remove()) + + const overlay = document.createElement('visbug-projective-transform') + overlay.source = element + overlay.suppress_overlays(Array.from( + document.querySelectorAll(selectionOverlays))) + document.body.appendChild(overlay) + return overlay +} diff --git a/app/plugins/projective-transform.test.js b/app/plugins/projective-transform.test.js new file mode 100644 index 00000000..23ebe8b4 --- /dev/null +++ b/app/plugins/projective-transform.test.js @@ -0,0 +1,188 @@ +import test from 'ava' +import { + setupPptrTab, teardownPptrTab, pptrMetaKey +} from '../../tests/helpers.js' + +const target = 'h2[style*="text-shadow"]' + +const shortcut = async (page, modifier, {shift = false} = {}) => { + await page.keyboard.down(modifier) + if (shift) await page.keyboard.down('Shift') + await page.keyboard.press('KeyZ') + if (shift) await page.keyboard.up('Shift') + await page.keyboard.up(modifier) +} + +const overlayMatchesSource = target => { + const source = document.querySelector(target) + const overlay = document.querySelector('visbug-projective-transform') + if (!source || !overlay) return false + + const quad = source.getBoxQuads()[0] + const sourcePoints = [quad.p1, quad.p2, quad.p3, quad.p4] + const overlayPoints = Array.from(overlay.$shadow.querySelectorAll('[data-corner]')) + .filter(element => element.tagName === 'g') + .map(group => { + const matrix = group.transform.baseVal.consolidate().matrix + return {x: matrix.e, y: matrix.f} + }) + + return overlayPoints.every((point, index) => + Math.abs(point.x - sourcePoints[index].x) < .1 + && Math.abs(point.y - sourcePoints[index].y) < .1) +} + +test.beforeEach(setupPptrTab) +test.afterEach.always(teardownPptrTab) + +test('shows four projective handles and supports undo and redo', async t => { + const {page} = t.context + const modifier = await pptrMetaKey(page) + + await page.click(target) + await page.$eval(target, element => + document.querySelector('vis-bug').execCommand('3d-transform', {source: element})) + + const overlays = await page.evaluate(() => ({ + projective: document.querySelectorAll('visbug-projective-transform').length, + regular: Array.from(document.querySelectorAll( + 'visbug-handles, visbug-label, visbug-rotation, visbug-hover' + )).filter(element => getComputedStyle(element).display !== 'none').length, + handles: document.querySelector('visbug-projective-transform') + .$shadow.querySelectorAll('.handle').length, + crosses: document.querySelector('visbug-projective-transform') + .$shadow.querySelectorAll('.cross').length, + })) + + t.deepEqual(overlays, { + projective: 1, + regular: 0, + handles: 4, + crosses: 8, + }) + + const handle = await page.$eval('visbug-projective-transform', overlay => { + const rect = overlay.$shadow.querySelector('.handle').getBoundingClientRect() + return {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2} + }) + + await page.mouse.move(handle.x, handle.y) + await page.mouse.down() + await page.mouse.move(handle.x + 35, handle.y + 20, {steps: 4}) + await page.mouse.up() + + const transformed = await page.$eval(target, element => element.style.transform) + t.regex(transformed, /matrix3d\(/) + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 1) + + await shortcut(page, modifier) + t.is(await page.$eval(target, element => element.style.transform), '') + await page.waitForFunction(overlayMatchesSource, {}, target) + + await shortcut(page, modifier, {shift: true}) + t.is(await page.$eval(target, element => element.style.transform), transformed) + + await page.click('article:nth-of-type(2)') + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 0) + t.is(await page.$$('visbug-handles').then(items => items.length), 1) +}) + +test('tracks undo through a transform transition', async t => { + const {page} = t.context + await page.$eval(target, element => { + element.style.transition = 'transform 150ms linear' + }) + await page.click(target) + await page.$eval(target, element => + document.querySelector('vis-bug').execCommand('3d-transform', {source: element})) + + const handle = await page.$eval('visbug-projective-transform', overlay => { + const rect = overlay.$shadow.querySelector('.handle').getBoundingClientRect() + return {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2} + }) + await page.mouse.move(handle.x, handle.y) + await page.mouse.down() + await page.mouse.move(handle.x + 30, handle.y + 20, {steps: 3}) + await page.mouse.up() + + await page.evaluate(() => document.querySelector('vis-bug').history.undo()) + await page.waitForFunction(overlayMatchesSource, {}, target) + + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 1) +}) + +test('changing tools closes the projective overlay', async t => { + const {page} = t.context + await page.click(target) + await page.$eval(target, element => + document.querySelector('vis-bug').execCommand('3d-transform', {source: element})) + + await page.evaluate(() => document.querySelector('vis-bug').toolSelected('margin')) + + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 0) +}) + +test('selection action replaces the regular selection overlays', async t => { + const {page} = t.context + await page.click(target) + + const action = await page.evaluate(async () => { + const menu = document.querySelector('visbug-handles').$shadow + .querySelector('visbug-selection-actions') + const shadow = menu.$shadow + shadow.querySelector('.trigger').click() + await new Promise(resolve => requestAnimationFrame(resolve)) + const button = shadow.querySelector('[data-command="3d-transform"]') + const bounds = button.getBoundingClientRect() + return {x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2} + }) + + await page.mouse.click(action.x, action.y) + await page.waitForSelector('visbug-projective-transform') + + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 1) + const regularOverlaysHidden = await page.$$eval( + 'visbug-handles, visbug-rotation', + elements => elements.every(element => getComputedStyle(element).display === 'none')) + t.true(regularOverlaysHidden) + + await page.click(target) + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 0) + t.is(await page.$$('visbug-handles').then(items => items.length), 1) +}) + +test('dragging on a scrolled page keeps the opposite corner in place', async t => { + const {page} = t.context + const before = await page.evaluate(() => { + const source = document.createElement('button') + source.id = 'scrolled-projective-target' + source.textContent = 'Transform me' + source.style.cssText = 'position:absolute;left:120px;top:2200px;width:160px;height:70px' + document.body.appendChild(source) + window.scrollTo(0, 2050) + + const visbug = document.querySelector('vis-bug') + visbug.selectorEngine.select(source) + visbug.execCommand('3d-transform', {source}) + const quad = source.getBoxQuads()[0] + return {x: quad.p3.x, y: quad.p3.y} + }) + + const handle = await page.$eval('visbug-projective-transform', overlay => { + const rect = overlay.$shadow.querySelector('.handle').getBoundingClientRect() + return {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2} + }) + await page.mouse.move(handle.x, handle.y) + await page.mouse.down() + await page.mouse.move(handle.x + 25, handle.y + 15, {steps: 3}) + await page.mouse.up() + + const after = await page.$eval('#scrolled-projective-target', source => { + const quad = source.getBoxQuads()[0] + return {x: quad.p3.x, y: quad.p3.y} + }) + + t.true(Math.abs(after.x - before.x) < .1) + t.true(Math.abs(after.y - before.y) < .1) + t.is(await page.$$('visbug-projective-transform').then(items => items.length), 1) +}) diff --git a/app/utilities/common.js b/app/utilities/common.js index a10ae074..87977e22 100644 --- a/app/utilities/common.js +++ b/app/utilities/common.js @@ -91,6 +91,7 @@ export const isOffBounds = node => || node.closest('visbug-grip') || node.closest('visbug-gridlines') || node.closest('visbug-rotation') + || node.closest('visbug-projective-transform') ) export const isSelectorValid = (qs => ( diff --git a/app/utilities/strings.js b/app/utilities/strings.js index 3c4cc163..4ba43280 100644 --- a/app/utilities/strings.js +++ b/app/utilities/strings.js @@ -44,4 +44,4 @@ export const altKey = window.navigator.platform.includes('Mac') ? 'opt' : 'alt' -export const notList = ':not(vis-bug):not(script):not(hotkey-map):not(.visbug-metatip):not(visbug-label):not(visbug-handles):not(visbug-corners):not(visbug-grip):not(visbug-gridlines):not(visbug-rotation)' +export const notList = ':not(vis-bug):not(script):not(hotkey-map):not(.visbug-metatip):not(visbug-label):not(visbug-handles):not(visbug-corners):not(visbug-grip):not(visbug-gridlines):not(visbug-rotation):not(visbug-projective-transform)' diff --git a/package.json b/package.json index 98994388..745ecd04 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "test": "ava", "test:dev": "ava -v -w", "test:server": "browser-sync start --server \"app\" --files \"app/index.html,app/bundle.css,app/bundle.js\" --no-open --no-notify --no-ui --no-ghost-mode", - "test:ci": "start-server-and-test http://localhost:3000" + "test:ci": "npm run bundle && start-server-and-test test:server http://localhost:3000/bundle.js test" }, "dependencies": { "@chenglou/pretext": "^0.0.8", diff --git a/tests/helpers.js b/tests/helpers.js index 6f70c20c..29fe357c 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -8,8 +8,11 @@ export const setupPptrTab = async t => { t.context.page = await t.context.browser.newPage() await t.context.page.goto('http://localhost:3000') + await t.context.page.waitForFunction(() => { + const visbug = document.querySelector('vis-bug') + return Boolean(visbug?.$shadow?.querySelector('li[data-tool]')) + }) await t.context.page.evaluateHandle(`document.body.setAttribute('testing', true)`) - await t.context.page.waitForSelector('vis-bug') } export const teardownPptrTab = async ({context:{ page, browser }}) => {