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 @@ + + +
of prose) is a sibling target, not a container + const hasElementChildren = el.children.length > 0 + const hasText = [...el.childNodes].some(n => + n.nodeType === 3 && n.textContent.trim().length) + + return hasElementChildren || !hasText +} + +/** Deepest element under the pointer that isn't chrome or the dragged node. */ +const hitTest = (x, y, dragged, doc) => + doc.elementsFromPoint(x, y) + .filter(el => + !isOffBounds(el) + && el !== dragged + && !dragged?.contains(el) + && el !== doc.documentElement) + +const resolveContainer = (x, y, dragged, doc) => { + const hits = hitTest(x, y, dragged, doc) + + for (const hit of hits) { + if (acceptsChildren(hit)) return hit + if (hit.parentElement && acceptsChildren(hit.parentElement)) + return hit.parentElement + } + + return doc.body && acceptsChildren(doc.body) ? doc.body : null +} + +/* ---------------------------------------------------------------- flow --- */ + +const start = (rect, axis) => axis === 'x' ? rect.left : rect.top +const end = (rect, axis) => axis === 'x' ? rect.right : rect.bottom +const mid = (rect, axis) => start(rect, axis) + (axis === 'x' ? rect.width : rect.height) / 2 + +/** + * Group items into visual lines along the cross axis — what makes a + * wrapped flex container or a run of inline elements resolve correctly. + */ +const intoLines = (items, axis) => { + const cross = axis === 'x' ? 'y' : 'x' + const sorted = [...items].sort((a, b) => + start(a.rect, cross) - start(b.rect, cross) + || start(a.rect, axis) - start(b.rect, axis)) + + const lines = [] + + sorted.forEach(item => { + const line = lines[lines.length - 1] + const overlaps = line && line.some(other => + start(item.rect, cross) < end(other.rect, cross) - 1 + && end(item.rect, cross) > start(other.rect, cross) + 1) + + overlaps ? line.push(item) : lines.push([item]) + }) + + return lines.map(line => + line.sort((a, b) => start(a.rect, axis) - start(b.rect, axis))) +} + +/** + * For anything that isn't flex or grid, `display` doesn't tell you the axis — + * a block container full of inline-block chips lays out in a row. Read the + * children's actual geometry instead: if adjacent items share a horizontal + * band but don't overlap horizontally, they're a row. + */ +const inferAxis = items => { + if (items.length < 2) return 'y' + + const sorted = [...items].sort((a, b) => a.rect.top - b.rect.top) + let side_by_side = 0 + + for (let i = 1; i < sorted.length; i++) { + const a = sorted[i - 1].rect + const b = sorted[i].rect + + const y_overlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) + const x_overlap = Math.min(a.right, b.right) - Math.max(a.left, b.left) + + if (y_overlap > 0 && x_overlap <= 0) side_by_side++ + } + + return side_by_side > (sorted.length - 1) / 2 ? 'x' : 'y' +} + +const barRect = (prev, next, axis, box) => { + const cross_start = prev && next + ? Math.min(start(prev.rect, axis === 'x' ? 'y' : 'x'), start(next.rect, axis === 'x' ? 'y' : 'x')) + : start((prev || next).rect, axis === 'x' ? 'y' : 'x') + + const cross_end = prev && next + ? Math.max(end(prev.rect, axis === 'x' ? 'y' : 'x'), end(next.rect, axis === 'x' ? 'y' : 'x')) + : end((prev || next).rect, axis === 'x' ? 'y' : 'x') + + // sit in the gap, or hug the single neighbour's edge + const at = prev && next + ? (end(prev.rect, axis) + start(next.rect, axis)) / 2 + : prev + ? end(prev.rect, axis) + : start(next.rect, axis) + + return axis === 'x' + ? { + left: at - BAR_THICKNESS / 2, + top: cross_start, + width: BAR_THICKNESS, + height: Math.max(BAR_THICKNESS, cross_end - cross_start), + } + : { + left: cross_start, + top: at - BAR_THICKNESS / 2, + width: Math.max(BAR_THICKNESS, cross_end - cross_start), + height: BAR_THICKNESS, + } +} + +const emptyContainerDrop = container => ({ + container, + mode: 'flow', + reference: null, + indicator: { rect: paddingBox(container), kind: 'container', axis: 'y' }, +}) + +const resolveFlow = ({ container, x, y, dragged, axis, wrapped, reversed }) => { + const items = childrenOf(container, dragged) + .map(el => ({ el, rect: el.getBoundingClientRect() })) + .filter(({ rect }) => rect.width || rect.height) + + if (!items.length) + return emptyContainerDrop(container) + + if (axis === 'auto') axis = inferAxis(items) + + const lines = wrapped ? intoLines(items, axis) : [ + [...items].sort((a, b) => start(a.rect, axis) - start(b.rect, axis)) + ] + + const cross = axis === 'x' ? 'y' : 'x' + const point = { x, y } + + // pick the line the pointer is on, else the nearest one + const line = lines.find(l => + point[cross] >= Math.min(...l.map(i => start(i.rect, cross))) + && point[cross] <= Math.max(...l.map(i => end(i.rect, cross))) + ) || lines.reduce((best, l) => { + const d = Math.min(...l.map(i => + Math.abs(mid(i.rect, cross) - point[cross]))) + return !best || d < best.d ? { line: l, d } : best + }, null).line + + // first item whose midpoint is past the pointer wins the "insert before" + const vi = line.findIndex(item => point[axis] < mid(item.rect, axis)) + const at = vi === -1 ? line.length : vi + + const prev = line[at - 1] || null + const next = line[at] || null + + // in a reversed track, visual order is the inverse of DOM order, so the + // node to insertBefore is the one on the *other* side of the caret + const reference = reversed + ? (prev ? prev.el : null) + : (next ? next.el : null) + + return { + container, + mode: 'flow', + reference, + indicator: { rect: barRect(prev, next, axis, contentBox(container)), kind: 'bar', axis }, + } +} + +/* ---------------------------------------------------------------- grid --- */ + +const gridCells = container => { + const s = cs(container) + const box = contentBox(container) + const cols = tracks(s.gridTemplateColumns) + const rows = tracks(s.gridTemplateRows) + + if (!cols.length || !rows.length) return null + + const col_gap = parseFloat(s.columnGap) || 0 + const row_gap = parseFloat(s.rowGap) || 0 + + const x_edges = [] + cols.reduce((x, w) => { x_edges.push({ start: x, size: w }); return x + w + col_gap }, box.left) + + const y_edges = [] + rows.reduce((y, h) => { y_edges.push({ start: y, size: h }); return y + h + row_gap }, box.top) + + return { x_edges, y_edges, col_gap, row_gap } +} + +const nearestTrack = (edges, pos, gap) => { + for (let i = 0; i < edges.length; i++) { + const { start, size } = edges[i] + if (pos < start + size + gap / 2) return i + } + return edges.length - 1 +} + +const isAutoPlaced = (el) => { + const s = cs(el) + return s.gridColumnStart === 'auto' && s.gridRowStart === 'auto' +} + +const resolveGrid = ({ container, x, y, dragged }) => { + const cells = gridCells(container) + if (!cells) return resolveFlow({ container, x, y, dragged, axis: 'auto', wrapped: true, reversed: false }) + + const items = childrenOf(container, dragged) + if (!items.length) return emptyContainerDrop(container) + + const col = nearestTrack(cells.x_edges, x, cells.col_gap) + const row = nearestTrack(cells.y_edges, y, cells.row_gap) + + const rect = { + left: cells.x_edges[col].start, + top: cells.y_edges[row].start, + width: cells.x_edges[col].size, + height: cells.y_edges[row].size, + } + + // Auto-flow grid: keep it auto-flowing — reorder in the DOM and let the + // browser re-place. Explicitly placed grid: pin the element to the cell. + const auto_flow = items.every(isAutoPlaced) + + if (!auto_flow) + return { + container, + mode: 'grid', + reference: null, + styles: { gridColumn: `${col + 1}`, gridRow: `${row + 1}` }, + indicator: { rect, kind: 'cell', axis: 'y' }, + } + + // the child currently occupying (or nearest past) this cell in flow order + const target = items.find(el => { + const r = el.getBoundingClientRect() + return r.top + r.height / 2 > rect.top && r.left + r.width / 2 > rect.left + }) || items.find(el => { + const r = el.getBoundingClientRect() + return r.top + r.height / 2 > rect.top + rect.height + }) || null + + return { + container, + mode: 'grid', + reference: target, + indicator: { rect, kind: 'cell', axis: 'y' }, + } +} + +/* ---------------------------------------------------------------- free --- */ + +const snapEdges = (container, dragged) => { + const box = paddingBox(container) + const xs = [box.left, box.left + box.width / 2, box.left + box.width] + const ys = [box.top, box.top + box.height / 2, box.top + box.height] + + childrenOf(container, dragged).forEach(el => { + const r = el.getBoundingClientRect() + xs.push(r.left, r.left + r.width / 2, r.right) + ys.push(r.top, r.top + r.height / 2, r.bottom) + }) + + return { xs, ys } +} + +const snapTo = (candidates, values) => { + let best = null + + values.forEach(value => + candidates.forEach(candidate => { + const delta = candidate - value + if (Math.abs(delta) <= SNAP_PX && (!best || Math.abs(delta) < Math.abs(best.delta))) + best = { delta, at: candidate } + })) + + return best +} + +/** + * Absolutely positioned elements don't participate in flow, so there's no + * caret to draw — snap their edges and centers to siblings instead. + */ +const resolveFree = ({ container, dragged, rect }) => { + const { xs, ys } = snapEdges(container, dragged) + + const snap_x = snapTo(xs, [rect.left, rect.left + rect.width / 2, rect.left + rect.width]) + const snap_y = snapTo(ys, [rect.top, rect.top + rect.height / 2, rect.top + rect.height]) + + return { + container, + mode: 'free', + reference: null, + snap: { + dx: snap_x ? snap_x.delta : 0, + dy: snap_y ? snap_y.delta : 0, + x: snap_x ? snap_x.at : null, + y: snap_y ? snap_y.at : null, + }, + indicator: { rect: paddingBox(container), kind: 'container', axis: 'y' }, + } +} + +/* ------------------------------------------------------------- resolve --- */ + +export const isFreeFloating = el => { + const position = cs(el).position + return position === 'absolute' || position === 'fixed' +} + +/** + * @returns {null | {container, mode, reference, indicator, styles?, snap?}} + * `reference` is the node to insertBefore — null means append. + */ +export const resolveDrop = ({ x, y, dragged, doc = dragged?.ownerDocument, rect = null }) => { + if (!dragged || !doc) return null + + const container = resolveContainer(x, y, dragged, doc) + if (!container || container === dragged || dragged.contains(container)) return null + + if (isFreeFloating(dragged) && cs(container).position !== 'static') + return resolveFree({ container, dragged, rect: rect || dragged.getBoundingClientRect() }) + + const display = cs(container).display + + if (display === 'grid' || display === 'inline-grid') + return resolveGrid({ container, x, y, dragged }) + + if (display === 'flex' || display === 'inline-flex') { + const s = cs(container) + const axis = s.flexDirection.startsWith('column') ? 'y' : 'x' + + return resolveFlow({ + container, x, y, dragged, axis, + wrapped: s.flexWrap !== 'nowrap', + reversed: s.flexDirection.endsWith('reverse'), + }) + } + + // table parts declare their own axis; everything else we measure + return resolveFlow({ + container, x, y, dragged, + axis: AXIS_BY_DISPLAY[display] || 'auto', + wrapped: true, + reversed: false, + }) +} + +export const __test__ = { + intoLines, nearestTrack, gridCells, acceptsChildren, contentBox, snapTo, inferAxis, +} diff --git a/app/features/dropzones.test.js b/app/features/dropzones.test.js new file mode 100644 index 00000000..ed31a63a --- /dev/null +++ b/app/features/dropzones.test.js @@ -0,0 +1,119 @@ +import test from 'ava' + +import { + setupFixtureTab, teardownPptrTab, changeMode, + centerOf, pointIn, dragFromTo, childText, +} from '../../tests/helpers' + +const tool = 'move' + +test.beforeEach(async t => { + await setupFixtureTab(t) + await changeMode({ tool, page: t.context.page }) +}) + +test('flex row: drops past the last item append it', async t => { + const { page } = t.context + + t.deepEqual(await childText(page, '#flex-row .box'), ['one', 'two', 'three']) + + const start = await pointIn(page, '#flex-row .box', 0, 0.5, 0.5) + const end = await pointIn(page, '#flex-row .box', 2, 0.95, 0.5) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#flex-row .box'), ['two', 'three', 'one']) +}) + +test('flex row: drops before an item insert, not swap', async t => { + const { page } = t.context + + // the old implementation swapped siblings; inserting is the difference + const start = await pointIn(page, '#flex-row .box', 2, 0.5, 0.5) + const end = await pointIn(page, '#flex-row .box', 0, 0.1, 0.5) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#flex-row .box'), ['three', 'one', 'two']) +}) + +test('flex column: resolves along the vertical axis', async t => { + const { page } = t.context + + const start = await pointIn(page, '#flex-col .box', 0, 0.5, 0.5) + const end = await pointIn(page, '#flex-col .box', 2, 0.5, 0.95) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#flex-col .box'), ['two', 'three', 'one']) +}) + +test('grid: an auto-flow grid reorders without pinning cells', async t => { + const { page } = t.context + + const start = await pointIn(page, '#grid-auto .box', 0, 0.5, 0.5) + const end = await pointIn(page, '#grid-auto .box', 3, 0.5, 0.5) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#grid-auto .box'), ['two', 'three', 'four', 'one']) + + // auto-placement must be preserved — no explicit grid-column/row written + const pinned = await page.$$eval('#grid-auto .box', els => + els.some(el => el.style.gridColumn || el.style.gridRow)) + t.false(pinned) +}) + +test('empty container accepts a drop', async t => { + const { page } = t.context + + // the old drag bailed out entirely when there were no siblings to swap with + const start = await pointIn(page, '#flex-row .box', 0, 0.5, 0.5) + const end = await centerOf(page, '#empty') + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#empty > *'), ['one']) +}) + +test('a drag is one undo step', async t => { + const { page } = t.context + + const before = await childText(page, '#flex-row .box') + + const start = await pointIn(page, '#flex-row .box', 0, 0.5, 0.5) + const end = await pointIn(page, '#flex-row .box', 2, 0.95, 0.5) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.notDeepEqual(await childText(page, '#flex-row .box'), before) + + await page.evaluate(() => + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'z', code: 'KeyZ', keyCode: 90, which: 90, + metaKey: true, ctrlKey: true, bubbles: true, cancelable: true, + }))) + + t.deepEqual(await childText(page, '#flex-row .box'), before) +}) + +test('a click without travel still selects rather than dragging', async t => { + const { page } = t.context + + const before = await childText(page, '#flex-row .box') + const at = await pointIn(page, '#flex-row .box', 1, 0.5, 0.5) + + await page.mouse.click(at.x, at.y) + + t.deepEqual(await childText(page, '#flex-row .box'), before) + t.true(await page.$eval('#flex-row .box:nth-child(2)', el => + el.hasAttribute('data-selected'))) +}) + +test.afterEach(teardownPptrTab) diff --git a/app/features/flex.js b/app/features/flex.js index fd611bf4..6355c0e3 100644 --- a/app/features/flex.js +++ b/app/features/flex.js @@ -1,5 +1,6 @@ import hotkeys from 'hotkeys-js' import { metaKey, getStyle } from '../utilities/' +import { editStyle } from '../core' const key_events = 'up,down,left,right' .split(',') @@ -53,7 +54,7 @@ export function Flex({selection}) { } const ensureFlex = el => { - el.style.display = 'flex' + editStyle(el, 'display', 'flex', 'flex') return el } @@ -71,7 +72,7 @@ export function changeDirection(els, value) { els .map(ensureFlex) .map(el => { - el.style.flexDirection = value + editStyle(el, 'flexDirection', value, 'flex direction') }) } @@ -94,7 +95,7 @@ export function changeHAlignment(els, direction) { : h_alignMap[payload.current] + 1 })) .forEach(({el, style, value}) => - el.style[style] = h_alignOptions[value < 0 ? 0 : value >= 2 ? 2: value]) + editStyle(el, style, h_alignOptions[value < 0 ? 0 : value >= 2 ? 2: value], 'align')) } const v_alignMap = {normal: 0,'flex-start': 0,'center': 1,'flex-end': 2,} @@ -116,7 +117,7 @@ export function changeVAlignment(els, direction) { : h_alignMap[payload.current] + 1 })) .forEach(({el, style, value}) => - el.style[style] = v_alignOptions[value < 0 ? 0 : value >= 2 ? 2: value]) + editStyle(el, style, v_alignOptions[value < 0 ? 0 : value >= 2 ? 2: value], 'align')) } const h_distributionMap = {normal: 1,'space-around': 0,'': 1,'space-between': 2,} @@ -138,7 +139,7 @@ export function changeHDistribution(els, direction) { : h_distributionMap[payload.current] + 1 })) .forEach(({el, style, value}) => - el.style[style] = h_distributionOptions[value < 0 ? 0 : value >= 2 ? 2: value]) + editStyle(el, style, h_distributionOptions[value < 0 ? 0 : value >= 2 ? 2: value], 'distribute')) } const v_distributionMap = {normal: 1,'space-around': 0,'': 1,'space-between': 2,} @@ -160,7 +161,7 @@ export function changeVDistribution(els, direction) { : v_distributionMap[payload.current] + 1 })) .forEach(({el, style, value}) => - el.style[style] = v_distributionOptions[value < 0 ? 0 : value >= 2 ? 2: value]) + editStyle(el, style, v_distributionOptions[value < 0 ? 0 : value >= 2 ? 2: value], 'distribute')) } const orderMap = {row: 0, 'row-reverse': 1, column: 2, 'column-reverse': 3,} @@ -184,7 +185,7 @@ export function changeOrder(els, direction) { ? orderMap[payload.current] : orderMap[payload.current] - 1 })) .forEach(({el, style, value}) => - el.style[style] = orderOptions[value]) + editStyle(el, style, orderOptions[value], 'order')) } const wrapMap = {nowrap: 0, 'wrap': 1,} @@ -208,5 +209,5 @@ export function changeWrap(els, direction) { ? wrapMap[payload.current] : wrapMap[payload.current] + 1 })) .forEach(({el, style, value}) => - el.style[style] = wrapOptions[value]) + editStyle(el, style, wrapOptions[value], 'wrap')) } diff --git a/app/features/flex.test.js b/app/features/flex.test.js index afacd9e6..84c2b08c 100644 --- a/app/features/flex.test.js +++ b/app/features/flex.test.js @@ -1,6 +1,6 @@ import test from 'ava' -import { setupPptrTab, teardownPptrTab, changeMode, getActiveTool, pptrMetaKey } +import { setupPptrTab, teardownPptrTab, changeMode, getActiveTool, pptrMetaKey, readStyle } from '../../tests/helpers' const tool = 'align' @@ -30,16 +30,16 @@ test('Can adjust justify-content', async t => { await page.click(test_selector) await page.keyboard.press('ArrowRight') - let justifyStr = await page.$eval(test_selector, el => el.style.justifyContent) + let justifyStr = await readStyle(page, test_selector, 'justifyContent') t.true(justifyStr === "center") await page.keyboard.press('ArrowRight') - justifyStr = await page.$eval(test_selector, el => el.style.justifyContent) + justifyStr = await readStyle(page, test_selector, 'justifyContent') t.true(justifyStr === "flex-end") await page.keyboard.press('ArrowLeft') await page.keyboard.press('ArrowLeft') - justifyStr = await page.$eval(test_selector, el => el.style.justifyContent) + justifyStr = await readStyle(page, test_selector, 'justifyContent') t.true(justifyStr === "flex-start") t.pass() @@ -51,16 +51,16 @@ test('Can adjust align-items', async t => { await page.click(test_selector) await page.keyboard.press('ArrowDown') - let alignStr = await page.$eval(test_selector, el => el.style.alignItems) + let alignStr = await readStyle(page, test_selector, 'alignItems') t.true(alignStr === "center") await page.keyboard.press('ArrowDown') - alignStr = await page.$eval(test_selector, el => el.style.alignItems) + alignStr = await readStyle(page, test_selector, 'alignItems') t.true(alignStr === "flex-end") await page.keyboard.press('ArrowUp') await page.keyboard.press('ArrowUp') - alignStr = await page.$eval(test_selector, el => el.style.alignItems) + alignStr = await readStyle(page, test_selector, 'alignItems') t.true(alignStr === "flex-start") t.pass() @@ -73,7 +73,7 @@ test('Can apply space-around', async t => { await page.click(test_selector) await page.keyboard.down('Shift') await page.keyboard.press('ArrowLeft') - let justifyStr = await page.$eval(test_selector, el => el.style.justifyContent) + let justifyStr = await readStyle(page, test_selector, 'justifyContent') t.true(justifyStr === "space-around") t.pass() @@ -86,7 +86,7 @@ test('Can apply space-between', async t => { await page.click(test_selector) await page.keyboard.down('Shift') await page.keyboard.press('ArrowRight') - let justifyStr = await page.$eval(test_selector, el => el.style.justifyContent) + let justifyStr = await readStyle(page, test_selector, 'justifyContent') t.true(justifyStr === "space-between") t.pass() @@ -100,19 +100,19 @@ test('Can adjust wrapping', async t => { await page.keyboard.down(metaKey) await page.keyboard.down('Shift') await page.keyboard.press('ArrowUp') - let wrapStr = await page.$eval(test_selector, el => el.style.flexWrap) + let wrapStr = await readStyle(page, test_selector, 'flexWrap') t.true(wrapStr === 'nowrap') await page.keyboard.press('ArrowUp') - wrapStr = await page.$eval(test_selector, el => el.style.flexWrap) + wrapStr = await readStyle(page, test_selector, 'flexWrap') t.true(wrapStr === 'nowrap') await page.keyboard.press('ArrowDown') - wrapStr = await page.$eval(test_selector, el => el.style.flexWrap) + wrapStr = await readStyle(page, test_selector, 'flexWrap') t.true(wrapStr === 'wrap') await page.keyboard.press('ArrowDown') - wrapStr = await page.$eval(test_selector, el => el.style.flexWrap) + wrapStr = await readStyle(page, test_selector, 'flexWrap') t.true(wrapStr === 'wrap') t.pass() @@ -126,19 +126,19 @@ test('Can adjust row order', async t => { await page.keyboard.down(metaKey) await page.keyboard.down('Shift') await page.keyboard.press('ArrowLeft') - let dirStr = await page.$eval(test_selector, el => el.style.flexDirection) + let dirStr = await readStyle(page, test_selector, 'flexDirection') t.true(dirStr === 'row-reverse') await page.keyboard.press('ArrowLeft') - dirStr = await page.$eval(test_selector, el => el.style.flexDirection) + dirStr = await readStyle(page, test_selector, 'flexDirection') t.true(dirStr === 'row-reverse') await page.keyboard.press('ArrowRight') - dirStr = await page.$eval(test_selector, el => el.style.flexDirection) + dirStr = await readStyle(page, test_selector, 'flexDirection') t.true(dirStr === 'row') await page.keyboard.press('ArrowRight') - dirStr = await page.$eval(test_selector, el => el.style.flexDirection) + dirStr = await readStyle(page, test_selector, 'flexDirection') t.true(dirStr === 'row') t.pass() @@ -153,19 +153,19 @@ test('Can adjust column order', async t => { await page.keyboard.press('ArrowUp') await page.keyboard.down('Shift') await page.keyboard.press('ArrowLeft') - let dirStr = await page.$eval(test_selector, el => el.style.flexDirection) + let dirStr = await readStyle(page, test_selector, 'flexDirection') t.true(dirStr === 'column-reverse') await page.keyboard.press('ArrowLeft') - dirStr = await page.$eval(test_selector, el => el.style.flexDirection) + dirStr = await readStyle(page, test_selector, 'flexDirection') t.true(dirStr === 'column-reverse') await page.keyboard.press('ArrowRight') - dirStr = await page.$eval(test_selector, el => el.style.flexDirection) + dirStr = await readStyle(page, test_selector, 'flexDirection') t.true(dirStr === 'column') await page.keyboard.press('ArrowRight') - dirStr = await page.$eval(test_selector, el => el.style.flexDirection) + dirStr = await readStyle(page, test_selector, 'flexDirection') t.true(dirStr === 'column') t.pass() diff --git a/app/features/font.js b/app/features/font.js index aa7861d1..bbf92790 100644 --- a/app/features/font.js +++ b/app/features/font.js @@ -1,12 +1,13 @@ import hotkeys from 'hotkeys-js' import { metaKey, getStyle, showHideSelected } from '../utilities/' +import { editStyle, getAuthoredStyle, getScale, stepStyleToken, tokenName } from '../core' -const key_events = 'up,down,left,right' - .split(',') - .reduce((events, event) => - `${events},${event},shift+${event}` - , '') - .substring(1) +const key_events = [ + ...'up,down,left,right'.split(',').flatMap(event => [event, `shift+${event}`]), + // alt is the px escape hatch: on a page with a type scale the bare arrows + // step the scale, and these keep the old ±1 / ±10 arithmetic available + ...'up,down'.split(',').flatMap(event => [`alt+${event}`, `alt+shift+${event}`]), +].join(',') const command_events = `${metaKey}+up,${metaKey}+down` @@ -23,6 +24,8 @@ export function Font({selection}) { keys.includes('shift') ? changeKerning(selectedNodes, handler.key) : changeAlignment(selectedNodes, handler.key) + else if (keys.includes('alt')) + changeFontSize(selectedNodes, handler.key, { raw_px: true }) else keys.includes('shift') ? changeLeading(selectedNodes, handler.key) @@ -36,19 +39,25 @@ export function Font({selection}) { }) hotkeys('cmd+b', e => { - selection().forEach(el => - el.style.fontWeight = - el.style.fontWeight == 'bold' - ? null - : 'bold') + const bold = getScale('fontWeight').find(({number}) => number === 700) + + selection().forEach(el => { + const authored = getAuthoredStyle(el, 'fontWeight') + const is_bold = authored == 'bold' + || (bold && tokenName(authored) === bold.name) + + editStyle(el, 'fontWeight', is_bold + ? null + : bold ? bold.css : 'bold', 'bold') + }) }) hotkeys('cmd+i', e => { selection().forEach(el => - el.style.fontStyle = - el.style.fontStyle == 'italic' + editStyle(el, 'fontStyle', + getAuthoredStyle(el, 'fontStyle') == 'italic' ? null - : 'italic') + : 'italic', 'italic')) }) return () => { @@ -60,29 +69,49 @@ export function Font({selection}) { } export function changeLeading(els, direction) { + const negative = direction.split('+').includes('down') + els .map(el => showHideSelected(el)) .map(el => ({ el, - style: 'lineHeight', - current: parseInt(getStyle(el, 'lineHeight')), - amount: 1, - negative: direction.split('+').includes('down'), + style: 'lineHeight', + font_size: parseFloat(getStyle(el, 'fontSize')), + // the px path has always rounded; the ratio below must not, or a + // 25.5px leading reads as 1.47 and re-snaps to the step it's already on + exact: parseFloat(getStyle(el, 'lineHeight')), + current: parseInt(getStyle(el, 'lineHeight')), + amount: 1, + negative, })) .map(payload => Object.assign(payload, { current: payload.current == 'normal' || isNaN(payload.current) - ? 1.14 * parseInt(getStyle(payload.el, 'fontSize')) // document this choice - : payload.current + ? 1.14 * payload.font_size // document this choice + : payload.current, + exact: isNaN(payload.exact) + ? 1.14 * payload.font_size + : payload.exact, })) .map(payload => Object.assign(payload, { + // line-height scales are authored as unitless ratios, but what the + // browser hands back is px — compare on the scale's own terms + token: stepStyleToken({ + el: payload.el, + prop: 'lineHeight', + kind: 'lineHeight', + delta: negative ? -1 : 1, + current: payload.exact / payload.font_size, + }), value: payload.negative ? payload.current - payload.amount : payload.current + payload.amount })) - .forEach(({el, style, value}) => - el.style[style] = `${value}px`) + .forEach(({el, style, token, value}) => + editStyle(el, style, token + ? token.css + : `${value}px`, 'leading')) } export function changeKerning(els, direction) { @@ -108,27 +137,39 @@ export function changeKerning(els, direction) { : (payload.current + payload.amount).toFixed(2) })) .forEach(({el, style, value}) => - el.style[style] = `${value <= -2 ? -2 : value}px`) + editStyle(el, style, `${value <= -2 ? -2 : value}px`, 'kerning')) } -export function changeFontSize(els, direction) { +export function changeFontSize(els, direction, { raw_px = false } = {}) { + const keys = direction.split('+') + const negative = keys.includes('down') + els .map(el => showHideSelected(el)) .map(el => ({ el, style: 'fontSize', current: parseInt(getStyle(el, 'fontSize')), - amount: direction.split('+').includes('shift') ? 10 : 1, - negative: direction.split('+').includes('down'), + amount: keys.includes('shift') ? 10 : 1, + negative, })) .map(payload => Object.assign(payload, { + token: raw_px ? null : stepStyleToken({ + el: payload.el, + prop: 'fontSize', + kind: 'fontSize', + delta: negative ? -1 : 1, + current: payload.current, + }), font_size: payload.negative ? payload.current - payload.amount : payload.current + payload.amount })) - .forEach(({el, style, font_size}) => - el.style[style] = `${font_size <= 6 ? 6 : font_size}px`) + .forEach(({el, style, token, font_size}) => + editStyle(el, style, token + ? token.css + : `${font_size <= 6 ? 6 : font_size}px`, 'font size')) } const weightMap = { @@ -151,15 +192,24 @@ export function changeFontWeight(els, direction) { })) .map(payload => Object.assign(payload, { + token: stepStyleToken({ + el: payload.el, + prop: 'fontWeight', + kind: 'fontWeight', + delta: payload.direction ? -1 : 1, + current: parseInt(payload.current, 10), + }), value: payload.direction ? weightMap[payload.current] - 1 : weightMap[payload.current] + 1 })) - .forEach(({el, style, value}) => - el.style[style] = weightOptions[value < 0 ? 0 : value >= weightOptions.length - ? weightOptions.length - : value - ]) + .forEach(({el, style, token, value}) => + editStyle(el, style, token + ? token.css + : weightOptions[value < 0 ? 0 : value >= weightOptions.length + ? weightOptions.length + : value + ], 'font weight')) } const alignMap = { @@ -186,5 +236,5 @@ export function changeAlignment(els, direction) { : alignMap[payload.current] + 1 })) .forEach(({el, style, value}) => - el.style[style] = alignOptions[value < 0 ? 0 : value >= 2 ? 2: value]) + editStyle(el, style, alignOptions[value < 0 ? 0 : value >= 2 ? 2: value], 'text align')) } diff --git a/app/features/font.test.js b/app/features/font.test.js index d33b1ef3..41036fd7 100644 --- a/app/features/font.test.js +++ b/app/features/font.test.js @@ -1,15 +1,13 @@ import test from 'ava' -import { setupPptrTab, teardownPptrTab, changeMode, getActiveTool, pptrMetaKey } +import { setupPptrTab, teardownPptrTab, changeMode, getActiveTool, pptrMetaKey, readStyle } from '../../tests/helpers' const tool = 'font' const test_selector = '[intro] b' const getInlineStyle = async (page, prop) => - await page.$eval(test_selector, (el, prop) => { - return el.style[prop] - }, prop) + await readStyle(page, test_selector, prop) test.beforeEach(async t => { await setupPptrTab(t) diff --git a/app/features/hueshift.js b/app/features/hueshift.js index 74227d7f..a3e69715 100644 --- a/app/features/hueshift.js +++ b/app/features/hueshift.js @@ -3,6 +3,7 @@ import hotkeys from 'hotkeys-js' import { TinyColor } from '@ctrl/tinycolor' import { metaKey, getStyle, showHideSelected } from '../utilities/' +import { editStyle, getAuthoredStyle } from '../core' const key_events = 'up,down,left,right' .split(',') @@ -90,7 +91,7 @@ export function changeHue(els, direction, prop, ColorTool) { case 'foreground': return { el, current: foreground.color.toHsl(), style: foreground.style } case 'border': { - if (el.style.border === '') el.style.border = '1px solid black' + if (getAuthoredStyle(el, 'border') === '') editStyle(el, 'border', '1px solid black', 'border') return { el, current: border.color.toHsl(), style: border.style } } } @@ -117,7 +118,7 @@ export function changeHue(els, direction, prop, ColorTool) { }) .forEach(({el, style, current}) => { let color = new TinyColor(current).setAlpha(current.a) - el.style[style] = color.toHslString() + editStyle(el, style, color.toHslString(), 'hue shift') if (style == 'color') ColorTool.foreground.color(color.toHslString()) if (style == 'backgroundColor') ColorTool.background.color(color.toHslString()) diff --git a/app/features/index.js b/app/features/index.js index 2af5becf..ff8e623f 100644 --- a/app/features/index.js +++ b/app/features/index.js @@ -2,7 +2,7 @@ export { Margin } from './margin' export { Selectable } from './selectable' export { Moveable } from './move' export { Padding } from './padding' -export { EditText } from './text' +export { EditText, exitTextEditing } from './text' export { Font } from './font' export { Flex } from './flex' export { Search } from './search' diff --git a/app/features/margin.js b/app/features/margin.js index 8b66ff30..ef939465 100644 --- a/app/features/margin.js +++ b/app/features/margin.js @@ -1,5 +1,6 @@ import hotkeys from 'hotkeys-js' import { metaKey, getStyle, getSide, showHideSelected } from '../utilities/' +import { editStyle, stepStyleToken } from '../core' const key_events = 'up,down,left,right' .split(',') @@ -35,23 +36,39 @@ export function Margin(visbug) { } export function pushElement(els, direction) { + const keys = direction.split('+') + const negative = keys.includes('alt') + const raw_px = keys.includes('shift') // see padding.js — shift means px + const style = 'margin' + getSide(direction) + els .map(el => showHideSelected(el)) .map(el => ({ el, - style: 'margin' + getSide(direction), - current: parseInt(getStyle(el, 'margin' + getSide(direction)), 10), - amount: direction.split('+').includes('shift') ? 10 : 1, - negative: direction.split('+').includes('alt'), + current: parseInt(getStyle(el, style), 10), + amount: raw_px ? 10 : 1, + negative, })) + .map(payload => + Object.assign(payload, { + token: raw_px ? null : stepStyleToken({ + el: payload.el, + prop: style, + kind: 'space', + delta: negative ? -1 : 1, + current: payload.current, + }) + })) .map(payload => Object.assign(payload, { margin: payload.negative ? payload.current - payload.amount : payload.current + payload.amount })) - .forEach(({el, style, margin}) => - el.style[style] = `${margin < 0 ? 0 : margin}px`) + .forEach(({el, token, margin}) => + editStyle(el, style, token + ? token.css + : `${margin < 0 ? 0 : margin}px`, 'margin')) } export function pushAllElementSides(els, keycommand) { diff --git a/app/features/margin.test.js b/app/features/margin.test.js index c12e4511..ce9bbd87 100644 --- a/app/features/margin.test.js +++ b/app/features/margin.test.js @@ -1,14 +1,13 @@ import test from 'ava' -import { setupPptrTab, teardownPptrTab, changeMode, getActiveTool } +import { setupPptrTab, teardownPptrTab, changeMode, getActiveTool, readStyle } from '../../tests/helpers' const tool = 'margin' const test_selector = '[intro] b' const getMarginTop = async page => - await page.$eval(test_selector, el => - el.style.marginTop) + await readStyle(page, test_selector, 'marginTop') test.beforeEach(async t => { await setupPptrTab(t) diff --git a/app/features/move.js b/app/features/move.js index d38f9e32..db988eb6 100644 --- a/app/features/move.js +++ b/app/features/move.js @@ -1,23 +1,27 @@ import $ from 'blingblingjs' import hotkeys from 'hotkeys-js' -import { getNodeIndex, showEdge, swapElements, notList } from '../utilities/' +import { getNodeIndex, showEdge, isFixed } from '../utilities/' +import { editStyle, history } from '../core' +import { resolveDrop } from './dropzones' import { toggleWatching } from './imageswap' const key_events = 'up,down,left,right' + +// pixels of travel before a mousedown becomes a drag rather than a click +const DRAG_THRESHOLD = 4 + const state = { drag: { src: null, - parent: null, - parent_ui: [], - siblings: new Map(), - swapping: new Map(), - }, - hover: { - dropzones: [], - observers: [], + active: false, + pointer_id: null, + origin: null, + offset: null, + rect: null, + drop: null, + indicator: null, }, } -// todo: indicator for when node can descend // todo: have it work with shadowDOM export function Moveable(visbug) { hotkeys(key_events, (e, {key}) => { @@ -46,33 +50,36 @@ export function Moveable(visbug) { export function moveElement(el, direction) { if (!el) return + const step = apply => + history.recordDOM(el, apply, 'nudge') + switch(direction) { case 'left': if (canMoveLeft(el)) - el.parentNode.insertBefore(el, el.previousElementSibling) + step(() => el.parentNode.insertBefore(el, el.previousElementSibling)) else showEdge(el.parentNode) break case 'right': if (canMoveRight(el) && el.nextElementSibling.nextSibling) - el.parentNode.insertBefore(el, el.nextElementSibling.nextSibling) + step(() => el.parentNode.insertBefore(el, el.nextElementSibling.nextSibling)) else if (canMoveRight(el)) - el.parentNode.appendChild(el) + step(() => el.parentNode.appendChild(el)) else showEdge(el.parentNode) break case 'up': if (canMoveUp(el)) - popOut({el}) + step(() => popOut({el})) break case 'down': if (canMoveUnder(el)) - popOut({el, under: true}) + step(() => popOut({el, under: true})) else if (canMoveDown(el)) - el.nextElementSibling.prepend(el) + step(() => el.nextElementSibling.prepend(el)) break } } @@ -91,221 +98,193 @@ export const popOut = ({el, under = false}) => : getNodeIndex(el)]) export function dragNDrop(selection) { - if (!selection.length) - return - clearListeners() - const [src] = selection - const {parentNode} = src - - const validMoveableChildren = [...parentNode.querySelectorAll(':scope > *' + notList)] + if (selection.length !== 1) return - const tooManySelected = selection.length !== 1 - const hasNoSiblingsToDrag = validMoveableChildren.length <= 1 - const isAnSVG = src instanceof SVGElement + const [src] = selection + if (src instanceof SVGElement) return - if (tooManySelected || hasNoSiblingsToDrag || isAnSVG) - return - - validMoveableChildren.forEach(sibling => - state.drag.siblings.set(sibling, createGripUI(sibling))) - - state.drag.parent = parentNode - state.drag.parent_ui = createParentUI(parentNode) - - moveWatch(state.drag.parent) + state.drag.src = src + src.style.cursor = 'grab' + $(src).on('pointerdown', onPointerDown) } -const moveWatch = node => { - const $node = $(node) +const onPointerDown = e => { + const src = state.drag.src + if (!src || e.button !== 0) return - $node.on('mouseleave', dragDrop) - $node.on('dragstart', dragStart) - $node.on('drop', dragDrop) - - state.drag.siblings.forEach((grip, sibling) => { - sibling.setAttribute('draggable', true) - $(sibling).on('dragover', dragOver) - $(sibling).on('mouseenter', siblingHoverIn) - $(sibling).on('mouseleave', siblingHoverOut) - }) -} + // let text selection and form controls keep working + if (e.target.isContentEditable || e.target.closest('input,textarea,select')) return -const moveUnwatch = node => { - const $node = $(node) + e.preventDefault() + e.stopPropagation() - $node.off('mouseleave', dragDrop) - $node.off('dragstart', dragStart) - $node.off('drop', dragDrop) + state.drag.pointer_id = e.pointerId + state.drag.origin = { x: e.clientX, y: e.clientY } + state.drag.rect = src.getBoundingClientRect() + state.drag.offset = { + x: e.clientX - state.drag.rect.left, + y: e.clientY - state.drag.rect.top, + } + state.drag.active = false - state.drag.siblings.forEach((grip, sibling) => { - sibling.removeAttribute('draggable') - $(sibling).off('dragover', dragOver) - $(sibling).off('mouseenter', siblingHoverIn) - $(sibling).off('mouseleave', siblingHoverOut) - }) + src.setPointerCapture(e.pointerId) + $(src).on('pointermove', onPointerMove) + $(src).on('pointerup', onPointerUp) + $(src).on('pointercancel', onPointerUp) } -const dragStart = ({target}) => { - if (!state.drag.siblings.has(target)) - return - - state.drag.src = target - state.hover.dropzones.push(createDropzoneUI(target)) - state.drag.siblings.get(target).style.opacity = 0.01 +const beginDrag = () => { + const src = state.drag.src - target.setAttribute('visbug-drag-src', true) - ghostNode(target) + state.drag.active = true + src.style.cursor = 'grabbing' - $('visbug-hover').forEach(el => - !el.hasAttribute('visbug-drag-container') && el.remove()) -} + history.beginGesture('move') -const dragOver = e => { - if ( - !state.drag.src || - state.drag.swapping.get(e.target) || - e.target.hasAttribute('visbug-drag-src') || - !state.drag.siblings.has(e.currentTarget) || - e.currentTarget !== e.target - ) return - - state.drag.swapping.set(e.target, true) - swapElements(state.drag.src, e.target) - - setTimeout(() => - state.drag.swapping.delete(e.target) - , 250) + ghostNode(src) + state.drag.indicator = createInsertionUI() } -const dragDrop = e => { - if (!state.drag.src) return +const onPointerMove = e => { + const src = state.drag.src + if (!src || e.pointerId !== state.drag.pointer_id) return - state.drag.src.removeAttribute('visbug-drag-src') - ghostBuster(state.drag.src) + const travelled = Math.hypot( + e.clientX - state.drag.origin.x, + e.clientY - state.drag.origin.y) - if (state.drag.siblings.has(state.drag.src)) - state.drag.siblings.get(state.drag.src).style.opacity = null + if (!state.drag.active) { + if (travelled < DRAG_THRESHOLD) return + beginDrag() + } - state.hover.dropzones.forEach(zone => - zone.remove()) + e.preventDefault() - state.drag.src = null -} + const rect = { + left: e.clientX - state.drag.offset.x, + top: e.clientY - state.drag.offset.y, + width: state.drag.rect.width, + height: state.drag.rect.height, + } -const siblingHoverIn = ({target}) => { - if (!state.drag.siblings.has(target)) - return + const drop = resolveDrop({ + x: e.clientX, + y: e.clientY, + dragged: src, + doc: src.ownerDocument, + rect, + }) - state.drag.siblings.get(target) - .toggleHovering({hovering:true}) -} + state.drag.drop = drop -const siblingHoverOut = ({target}) => { - if (!state.drag.siblings.has(target)) + if (!drop) { + state.drag.indicator.style.display = 'none' return + } - state.drag.siblings.get(target) - .toggleHovering({hovering:false}) -} - -const ghostNode = ({style}) => { - style.transition = 'opacity .25s ease-out' - style.opacity = 0.01 -} + state.drag.indicator.style.display = '' + state.drag.indicator.placement = { + ...drop.indicator, + isFixed: isFixed(drop.container), + } -const ghostBuster = ({style}) => { - style.transition = null - style.opacity = null + // free placement follows the cursor live; flow drops preview via the caret + if (drop.mode === 'free') + moveFreely(src, rect, drop) } -const createDropzoneUI = el => { - const zone = document.createElement('visbug-corners') +const moveFreely = (src, rect, drop) => { + const x = rect.left + (drop.snap?.dx || 0) + const y = rect.top + (drop.snap?.dy || 0) - zone.position = {el} - document.body.appendChild(zone) + // fixed elements are already positioned against the viewport; absolute ones + // resolve against their offset parent, so subtract that origin + const origin = getComputedStyle(src).position === 'fixed' + ? { left: 0, top: 0 } + : (src.offsetParent || drop.container).getBoundingClientRect() - const observer = new MutationObserver(list => - zone.position = {el}) - - observer.observe(el.parentNode, { - childList: true, - subtree: true, - }) - - state.hover.observers.push(observer) - - return zone + editStyle(src, 'left', `${Math.round(x - origin.left)}px`, 'move') + editStyle(src, 'top', `${Math.round(y - origin.top)}px`, 'move') } -const createGripUI = el => { - const grip = document.createElement('visbug-grip') - - grip.position = {el} - document.body.appendChild(grip) - - const observer = new MutationObserver(list => - grip.position = {el}) +const onPointerUp = e => { + const src = state.drag.src + if (!src) return - observer.observe(el.parentNode, { - childList: true, - subtree: true, - }) + $(src).off('pointermove', onPointerMove) + $(src).off('pointerup', onPointerUp) + $(src).off('pointercancel', onPointerUp) - state.hover.observers.push(observer) + if (src.hasPointerCapture?.(state.drag.pointer_id)) + src.releasePointerCapture(state.drag.pointer_id) - return grip -} + if (!state.drag.active) return -const createParentUI = parent => { - const hover = document.createElement('visbug-hover') - const label = document.createElement('visbug-label') + const drop = state.drag.drop - hover.position = {el:parent} - hover.setAttribute('visbug-drag-container', true) + if (drop && drop.mode !== 'free') { + // one insert, one undo entry, even though the pointer moved a hundred times + if (drop.reference !== src && drop.container !== src) + history.recordDOM(src, () => + drop.container.insertBefore(src, drop.reference), 'move') - label.text = 'Drag Bounds' - label.position = {boundingRect: parent.getBoundingClientRect()} - label.style.setProperty('--label-bg', 'var(--theme-purple)') + if (drop.styles) + Object.entries(drop.styles).forEach(([prop, value]) => + editStyle(src, prop, value, 'move')) + } - document.body.appendChild(hover) - document.body.appendChild(label) + history.endGesture() - const observer = new MutationObserver(list => { - hover.position = {el:parent} - label.position = {boundingRect: parent.getBoundingClientRect()} - }) + ghostBuster(src) + src.style.cursor = 'grab' + state.drag.active = false + state.drag.drop = null - observer.observe(parent, { - childList: true, - subtree: true, - }) + clearIndicator() +} - state.hover.observers.push(observer) +const createInsertionUI = () => { + const indicator = document.createElement('visbug-insertion') + document.body.appendChild(indicator) + return indicator +} - return [hover,label] +const clearIndicator = () => { + state.drag.indicator?.remove() + state.drag.indicator = null } export function clearListeners() { - moveUnwatch(state.drag.parent) + const src = state.drag.src + + if (src) { + $(src).off('pointerdown', onPointerDown) + $(src).off('pointermove', onPointerMove) + $(src).off('pointerup', onPointerUp) + $(src).off('pointercancel', onPointerUp) + src.style.cursor = null + ghostBuster(src) + } - state.hover.observers.forEach(observer => - observer.disconnect()) + if (state.drag.active) history.endGesture() - state.hover.dropzones.forEach(zone => - zone.remove()) + clearIndicator() - state.drag.siblings.forEach((grip, sibling) => - grip.remove()) + state.drag.src = null + state.drag.drop = null + state.drag.active = false +} - state.drag.parent_ui.forEach(ui => - ui.remove()) +const ghostNode = ({style}) => { + style.transition = 'opacity .15s ease-out' + style.opacity = 0.4 +} - state.hover.observers = [] - state.hover.dropzones = [] - state.drag.parent_ui = [] - state.drag.siblings.clear() +const ghostBuster = ({style}) => { + style.transition = null + style.opacity = null } const updateFeedback = el => { diff --git a/app/features/padding.js b/app/features/padding.js index 9f672b12..8fb3b540 100644 --- a/app/features/padding.js +++ b/app/features/padding.js @@ -1,5 +1,6 @@ import hotkeys from 'hotkeys-js' import { metaKey, getStyle, getSide, showHideSelected, expandBorders } from '../utilities/' +import { editStyle, stepStyleToken } from '../core' const key_events = 'up,down,left,right' .split(',') @@ -35,23 +36,41 @@ export function Padding(visbug) { } export function padElement(els, direction) { + const keys = direction.split('+') + const negative = keys.includes('alt') + // shift has always meant "coarser"; on a page with a spacing scale it doubles + // as the escape hatch back to raw px, since the scale is the coarse mode now + const raw_px = keys.includes('shift') + const style = 'padding' + getSide(direction) + els .map(el => showHideSelected(el)) .map(el => ({ el, - style: 'padding' + getSide(direction), - current: parseInt(getStyle(el, 'padding' + getSide(direction)), 10), - amount: direction.split('+').includes('shift') ? 10 : 1, - negative: direction.split('+').includes('alt'), + current: parseInt(getStyle(el, style), 10), + amount: raw_px ? 10 : 1, + negative, })) + .map(payload => + Object.assign(payload, { + token: raw_px ? null : stepStyleToken({ + el: payload.el, + prop: style, + kind: 'space', + delta: negative ? -1 : 1, + current: payload.current, + }) + })) .map(payload => Object.assign(payload, { padding: payload.negative ? payload.current - payload.amount : payload.current + payload.amount })) - .forEach(({el, style, padding}) => - el.style[style] = `${padding < 0 ? 0 : padding}px`) + .forEach(({el, token, padding}) => + editStyle(el, style, token + ? token.css + : `${padding < 0 ? 0 : padding}px`, 'padding')) } export function padAllElementSides(els, keycommand) { diff --git a/app/features/padding.test.js b/app/features/padding.test.js index 70aaacd8..a221cb25 100644 --- a/app/features/padding.test.js +++ b/app/features/padding.test.js @@ -1,14 +1,13 @@ import test from 'ava' -import { setupPptrTab, teardownPptrTab, changeMode, getActiveTool } +import { setupPptrTab, teardownPptrTab, changeMode, getActiveTool, readStyle } from '../../tests/helpers' const tool = 'padding' const test_selector = '[intro] b' const getPaddingTop = async page => - await page.$eval(test_selector, el => - el.style.paddingTop) + await readStyle(page, test_selector, 'paddingTop') test.beforeEach(async t => { await setupPptrTab(t) diff --git a/app/features/position.js b/app/features/position.js index 25a70993..a2bc724f 100644 --- a/app/features/position.js +++ b/app/features/position.js @@ -1,6 +1,7 @@ import $ from 'blingblingjs' import hotkeys from 'hotkeys-js' import { metaKey, getStyle, getSide, showHideSelected } from '../utilities/' +import { editStyle, history } from '../core' const key_events = 'up,down,left,right' .split(',') @@ -81,8 +82,10 @@ export function draggable({el, surface = el, cursor = 'move', clickEvent}) { if(e.target !== state.surface) return e.preventDefault() + history.beginGesture('drag') + if(getComputedStyle(el).position == 'static') - el.style.position = 'relative' + editStyle(el, 'position', 'relative', 'position') el.style.willChange = 'top,left' if (el instanceof SVGElement) { @@ -114,6 +117,7 @@ export function draggable({el, surface = el, cursor = 'move', clickEvent}) { state.mouse.down = false el.style.willChange = null + history.endGesture() if (el instanceof SVGElement) { const translate = el.getAttribute('transform') @@ -149,8 +153,8 @@ export function draggable({el, surface = el, cursor = 'move', clickEvent}) { )`) } else { - el.style.left = state.element.x + e.clientX - state.mouse.x + 'px' - el.style.top = state.element.y + e.clientY - state.mouse.y + 'px' + editStyle(el, 'left', state.element.x + e.clientX - state.mouse.x + 'px', 'drag') + editStyle(el, 'top', state.element.y + e.clientY - state.mouse.y + 'px', 'drag') } state.travelDistance += 1 @@ -181,7 +185,7 @@ export function positionElement(els, direction) { .forEach(({el, style, position}) => el instanceof SVGElement ? setTranslateOnSVG(el, direction, position) - : el.style[style] = position + 'px') + : editStyle(el, style, position + 'px', 'position')) } const extractCurrentValueAndSide = (el, direction) => { @@ -237,6 +241,6 @@ const determineNegativity = (el, direction) => const ensurePositionable = el => { if (el instanceof HTMLElement) - el.style.position = 'relative' + editStyle(el, 'position', 'relative', 'position') return el } diff --git a/app/features/selectable.js b/app/features/selectable.js index 71d48879..69b39547 100644 --- a/app/features/selectable.js +++ b/app/features/selectable.js @@ -18,6 +18,7 @@ import { isSelectorValid, findNearestChildElement, findNearestParentElement, getTextShadowValues, isFixed, onRemove } from '../utilities/' +import { editStyle, editClearStyles, history } from '../core' export function Selectable(visbug) { const page = document.body @@ -44,6 +45,8 @@ export function Selectable(visbug) { watchCommandKey() + hotkeys(`${metaKey}+z`, on_undo) + hotkeys(`${metaKey}+shift+z,${metaKey}+y`, on_redo) hotkeys(`${metaKey}+alt+c`, on_copy_styles) hotkeys(`${metaKey}+alt+v`, e => on_paste_styles()) hotkeys('esc', on_esc) @@ -147,13 +150,35 @@ export function Selectable(visbug) { const on_esc = _ => unselect_all() + const on_undo = e => { + e.preventDefault() + history.undo() && refresh_selection() + } + + const on_redo = e => { + e.preventDefault() + history.redo() && refresh_selection() + } + + // undo can reparent or remove what's selected, so drop anything that left + // the tree and let the overlays re-measure what's still there + const refresh_selection = () => { + selected + .filter(el => !el.isConnected) + .map(el => el.getAttribute('data-label-id')) + .forEach(id => unselect(id)) + + tellWatchers() + } + const on_duplicate = e => { const root_node = selected[0] if (!root_node) return const deep_clone = root_node.cloneNode(true) deep_clone.removeAttribute('data-selected') - root_node.parentNode.insertBefore(deep_clone, root_node.nextSibling) + history.recordDOM(deep_clone, () => + root_node.parentNode.insertBefore(deep_clone, root_node.nextSibling), 'duplicate') e.preventDefault() } @@ -161,8 +186,11 @@ export function Selectable(visbug) { selected.length && delete_all() const on_clearstyles = e => - selected.forEach(el => - el.attr('style', null)) + history.transact('clear styles', () => + selected.forEach(el => { + editClearStyles(el) + el.attr('style', null) + })) const on_copy = async e => { // if user has selected text, dont try to copy an element @@ -252,7 +280,7 @@ export function Selectable(visbug) { selected.forEach(el => { window.copied_styles[index] .map(({prop, value}) => - el.style[prop] = value) + editStyle(el, prop, value, 'paste styles')) index >= window.copied_styles.length - 1 ? index = 0 @@ -289,25 +317,27 @@ export function Selectable(visbug) { if (key.split('+').includes('shift')) { let $selected = [...selected] unselect_all() - $selected.reverse().forEach(el => { - let l = el.children.length - while (el.children.length > 0) { - var node = el.childNodes[el.children.length - 1] - if (node.nodeName !== '#text') - select(node) - el.parentNode.prepend(node) - } - el.parentNode.removeChild(el) - }) + history.transact('ungroup', () => + $selected.reverse().forEach(el => { + while (el.children.length > 0) { + var node = el.childNodes[el.children.length - 1] + if (node.nodeName !== '#text') + select(node) + history.recordDOM(node, () => el.parentNode.prepend(node), 'ungroup') + } + history.recordDOM(el, () => el.parentNode.removeChild(el), 'ungroup') + })) } else { let div = document.createElement('div') - selected[0].parentNode.prepend( - selected.reverse().reduce((div, el) => { - div.appendChild(el) - return div - }, div) - ) + const anchor = selected[0].parentNode + + history.transact('group', () => { + selected.reverse().forEach(el => + history.recordDOM(el, () => div.appendChild(el), 'group')) + history.recordDOM(div, () => anchor.prepend(div), 'group') + }) + unselect_all() select(div) } @@ -500,7 +530,11 @@ export function Selectable(visbug) { else if (el.parentNode) return el.parentNode }) - Array.from([...selected, ...labels, ...handles]).forEach(el => + history.transact('delete', () => + selected.forEach(el => + history.recordDOM(el, () => el.remove(), 'delete'))) + + Array.from([...labels, ...handles]).forEach(el => el.remove()) labels = [] diff --git a/app/features/text.js b/app/features/text.js index 2d945bb0..df231678 100644 --- a/app/features/text.js +++ b/app/features/text.js @@ -35,4 +35,7 @@ export function EditText(elements) { }) hotkeys('escape,esc', cleanup) -} \ No newline at end of file +} + +/** Ends any in-progress editing, so switching tools can't strand a live caret. */ +export const exitTextEditing = () => cleanup() \ No newline at end of file diff --git a/app/optics-tokens.css b/app/optics-tokens.css new file mode 100644 index 00000000..be4bb657 --- /dev/null +++ b/app/optics-tokens.css @@ -0,0 +1,118 @@ +/** + * Optics design tokens — a fixture, not a dependency. + * + * Excerpted verbatim from @rolemodel/optics@2.4.0 (dist/css/optics.css) so the + * token tests can run offline against real values rather than invented ones. + * Only the scales VisBug steps through are here; the full system is at + * https://docs.optics.rolemodel.design + */ + +html { + font-size: 62.5%; /* 1rem = 10px — the whole scale is built on this */ +} + +:root { + /* spacing */ + --op-space-scale-unit: 1rem; + --op-space-3x-small: calc(var(--op-space-scale-unit) * 0.2); + --op-space-2x-small: calc(var(--op-space-scale-unit) * 0.4); + --op-space-x-small: calc(var(--op-space-scale-unit) * 0.8); + --op-space-small: calc(var(--op-space-scale-unit) * 1.2); + --op-space-medium: calc(var(--op-space-scale-unit) * 1.6); + --op-space-large: calc(var(--op-space-scale-unit) * 2); + --op-space-x-large: calc(var(--op-space-scale-unit) * 2.4); + --op-space-2x-large: calc(var(--op-space-scale-unit) * 2.8); + --op-space-3x-large: calc(var(--op-space-scale-unit) * 4); + --op-space-4x-large: calc(var(--op-space-scale-unit) * 8); + + /* type */ + --op-font-scale-unit: 1rem; + --op-font-2x-small: calc(var(--op-font-scale-unit) * 1); + --op-font-x-small: calc(var(--op-font-scale-unit) * 1.2); + --op-font-small: calc(var(--op-font-scale-unit) * 1.4); + --op-font-medium: calc(var(--op-font-scale-unit) * 1.6); + --op-font-large: calc(var(--op-font-scale-unit) * 1.8); + --op-font-x-large: calc(var(--op-font-scale-unit) * 2); + --op-font-2x-large: calc(var(--op-font-scale-unit) * 2.4); + --op-font-3x-large: calc(var(--op-font-scale-unit) * 2.8); + --op-font-4x-large: calc(var(--op-font-scale-unit) * 3.2); + --op-font-5x-large: calc(var(--op-font-scale-unit) * 3.6); + --op-font-6x-large: calc(var(--op-font-scale-unit) * 4.8); + --op-font-weight-thin: 100; + --op-font-weight-extra-light: 200; + --op-font-weight-light: 300; + --op-font-weight-normal: 400; + --op-font-weight-medium: 500; + --op-font-weight-semi-bold: 600; + --op-font-weight-bold: 700; + --op-font-weight-extra-bold: 800; + --op-font-weight-black: 900; + --op-font-family: 'Noto Sans', sans-serif; + --op-font-family-alt: 'Noto Serif', serif; + + /* line height */ + --op-line-height-none: 0; + --op-line-height-densest: 1; + --op-line-height-denser: 1.15; + --op-line-height-dense: 1.3; + --op-line-height-base: 1.5; + --op-line-height-loose: 1.6; + --op-line-height-looser: 1.7; + --op-line-height-loosest: 1.8; + + /* radius */ + --op-radius-small: 2px; + --op-radius-medium: 4px; + --op-radius-large: 8px; + --op-radius-x-large: 12px; + --op-radius-2x-large: 16px; + --op-radius-circle: 50%; + --op-radius-pill: 9999px; + + /* shadow */ + --op-shadow-x-small: 0 1px 2px hsl(0deg 0% 0% / 3%), 0 1px 3px hsl(0deg 0% 0% / 15%); + --op-shadow-small: 0 1px 2px hsl(0deg 0% 0% / 3%), 0 2px 6px hsl(0deg 0% 0% / 15%); + --op-shadow-medium: 0 4px 8px hsl(0deg 0% 0% / 15%), 0 1px 3px hsl(0deg 0% 0% / 3%); + --op-shadow-large: 0 6px 10px hsl(0deg 0% 0% / 15%), 0 2px 3px hsl(0deg 0% 0% / 3%); + --op-shadow-x-large: 0 8px 12px hsl(0deg 0% 0% / 15%), 0 4px 4px hsl(0deg 0% 0% / 3%); + + /* color channels */ + --op-color-primary-h: 216; + --op-color-primary-s: 58%; + --op-color-primary-l: 48%; + --op-color-neutral-h: var(--op-color-primary-h); + --op-color-neutral-s: 4%; + --op-color-neutral-l: var(--op-color-primary-l); + --op-color-alerts-warning-h: 47; + --op-color-alerts-warning-s: 100%; + --op-color-alerts-warning-l: 61%; + --op-color-alerts-danger-h: 0; + --op-color-alerts-danger-s: 99%; + --op-color-alerts-danger-l: 76%; + --op-color-alerts-info-h: 216; + --op-color-alerts-info-s: 58%; + --op-color-alerts-info-l: 48%; + --op-color-alerts-notice-h: 130; + --op-color-alerts-notice-s: 61%; + --op-color-alerts-notice-l: 64%; + + /* color */ + --op-color-primary-plus-three: light-dark( hsl(var(--op-color-primary-h) var(--op-color-primary-s) 70%), hsl(var(--op-color-primary-h) var(--op-color-primary-s) 29%) ); + --op-color-primary-base: light-dark( hsl(var(--op-color-primary-h) var(--op-color-primary-s) 40%), hsl(var(--op-color-primary-h) var(--op-color-primary-s) 38%) ); + --op-color-primary-minus-three: light-dark( hsl(var(--op-color-primary-h) var(--op-color-primary-s) 28%), hsl(var(--op-color-primary-h) var(--op-color-primary-s) 48%) ); + --op-color-neutral-plus-three: light-dark( hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 70%), hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 24%) ); + --op-color-neutral-base: light-dark( hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 40%), hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 32%) ); + --op-color-neutral-minus-three: light-dark( hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 28%), hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 44%) ); + --op-color-alerts-warning-plus-three: light-dark( hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 70%), hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 20%) ); + --op-color-alerts-warning-base: light-dark( hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 40%), hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 32%) ); + --op-color-alerts-warning-minus-three: light-dark( hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 18%), hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 48%) ); + --op-color-alerts-danger-plus-three: light-dark( hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 70%), hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 20%) ); + --op-color-alerts-danger-base: light-dark( hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 40%), hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 32%) ); + --op-color-alerts-danger-minus-three: light-dark( hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 28%), hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 48%) ); + --op-color-alerts-info-plus-three: light-dark( hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 70%), hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 20%) ); + --op-color-alerts-info-base: light-dark( hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 40%), hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 32%) ); + --op-color-alerts-info-minus-three: light-dark( hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 28%), hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 48%) ); + --op-color-alerts-notice-plus-three: light-dark( hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 70%), hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 20%) ); + --op-color-alerts-notice-base: light-dark( hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 40%), hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 32%) ); + --op-color-alerts-notice-minus-three: light-dark( hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 26%), hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 48%) ); +} diff --git a/app/optics.html b/app/optics.html new file mode 100644 index 00000000..e9f0ff28 --- /dev/null +++ b/app/optics.html @@ -0,0 +1,66 @@ + + +