mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(studio): the book engine in shared: layouts, grids, page ops and findings
This commit is contained in:
@@ -1,178 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { BookPageSetup } from '@trek/shared'
|
||||
import { GRIDS, defaultGridBox, gridElements } from '../../../src/components/Studio/grids'
|
||||
import { bookPageSetupSchema } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Photo grids (#1973).
|
||||
*
|
||||
* The rules worth pinning are geometric: a grid must tile the box it is given
|
||||
* without gaps or overlaps, and the block it produces must occupy exactly that
|
||||
* box — a grid asked to fill the safe area that overhangs it by one gutter is
|
||||
* a grid that has to be dragged before it is any use.
|
||||
*/
|
||||
|
||||
const page: BookPageSetup = bookPageSetupSchema.parse({
|
||||
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
|
||||
})
|
||||
|
||||
const box = { x: 0, y: 0, w: 100, h: 100 }
|
||||
|
||||
describe('the grid catalogue', () => {
|
||||
it('gives every grid a unique id', () => {
|
||||
const ids = GRIDS.map(g => g.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('gives every grid at least one cell', () => {
|
||||
for (const grid of GRIDS) expect(grid.cells.length, grid.id).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps every cell inside the unit block', () => {
|
||||
for (const grid of GRIDS) {
|
||||
for (const cell of grid.cells) {
|
||||
expect(cell.x, grid.id).toBeGreaterThanOrEqual(0)
|
||||
expect(cell.y, grid.id).toBeGreaterThanOrEqual(0)
|
||||
expect(cell.x + cell.w, grid.id).toBeLessThanOrEqual(1.0001)
|
||||
expect(cell.y + cell.h, grid.id).toBeLessThanOrEqual(1.0001)
|
||||
expect(cell.w, grid.id).toBeGreaterThan(0)
|
||||
expect(cell.h, grid.id).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* Tiling grids cover the block exactly. The staggered and band arrangements
|
||||
* deliberately do not — they are compositions with air around them — so they
|
||||
* are checked for "no overlap" only, further down.
|
||||
*/
|
||||
it('tiles the block completely, for the grids that are meant to', () => {
|
||||
const tiling = GRIDS.filter(g => !/stagger|band|filmstrip/.test(g.id))
|
||||
for (const grid of tiling) {
|
||||
const area = grid.cells.reduce((sum, c) => sum + c.w * c.h, 0)
|
||||
expect(area, grid.id).toBeCloseTo(1, 5)
|
||||
}
|
||||
})
|
||||
|
||||
it('never overlaps two cells', () => {
|
||||
for (const grid of GRIDS) {
|
||||
for (let i = 0; i < grid.cells.length; i++) {
|
||||
for (let j = i + 1; j < grid.cells.length; j++) {
|
||||
const a = grid.cells[i]
|
||||
const b = grid.cells[j]
|
||||
const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
|
||||
const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
|
||||
const overlaps = overlapX > 0.0001 && overlapY > 0.0001
|
||||
expect(overlaps, `${grid.id}: cell ${i} and ${j}`).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('gridElements', () => {
|
||||
it('makes one empty photo frame per cell', () => {
|
||||
const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 0)
|
||||
expect(els).toHaveLength(4)
|
||||
for (const el of els) {
|
||||
expect(el.kind).toBe('photo')
|
||||
expect(el.kind === 'photo' && el.photoId).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every frame its own id', () => {
|
||||
const els = gridElements(GRIDS.find(g => g.id === 'grid-9')!, box, 0)
|
||||
expect(new Set(els.map(e => e.id)).size).toBe(els.length)
|
||||
})
|
||||
|
||||
it('fills the box exactly when there is no gutter', () => {
|
||||
const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 0)
|
||||
const left = Math.min(...els.map(e => e.frame.x))
|
||||
const top = Math.min(...els.map(e => e.frame.y))
|
||||
const right = Math.max(...els.map(e => e.frame.x + e.frame.w))
|
||||
const bottom = Math.max(...els.map(e => e.frame.y + e.frame.h))
|
||||
expect(left).toBeCloseTo(0, 6)
|
||||
expect(top).toBeCloseTo(0, 6)
|
||||
expect(right).toBeCloseTo(100, 6)
|
||||
expect(bottom).toBeCloseTo(100, 6)
|
||||
})
|
||||
|
||||
/*
|
||||
* The gutter comes out of the cells, not out of the block. Added between them
|
||||
* instead, a four-across grid would be three gutters wider than the box it
|
||||
* was told to fill, and would hang off the safe area it was aligned to.
|
||||
*/
|
||||
it('keeps the block inside its box once a gutter is applied', () => {
|
||||
const els = gridElements(GRIDS.find(g => g.id === 'grid-6')!, box, 4)
|
||||
for (const el of els) {
|
||||
expect(el.frame.x).toBeGreaterThanOrEqual(-0.001)
|
||||
expect(el.frame.y).toBeGreaterThanOrEqual(-0.001)
|
||||
expect(el.frame.x + el.frame.w).toBeLessThanOrEqual(100.001)
|
||||
expect(el.frame.y + el.frame.h).toBeLessThanOrEqual(100.001)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the block edges flush and only splits the interior gaps', () => {
|
||||
const gutter = 6
|
||||
const els = gridElements(GRIDS.find(g => g.id === 'two-across')!, box, gutter)
|
||||
const [a, b] = [...els].sort((x, y) => x.frame.x - y.frame.x)
|
||||
// Outer edges untouched...
|
||||
expect(a.frame.x).toBeCloseTo(0, 6)
|
||||
expect(b.frame.x + b.frame.w).toBeCloseTo(100, 6)
|
||||
// ...and exactly one gutter between them.
|
||||
expect(b.frame.x - (a.frame.x + a.frame.w)).toBeCloseTo(gutter, 6)
|
||||
})
|
||||
|
||||
it('offsets the whole block when the box does not start at the origin', () => {
|
||||
const els = gridElements(GRIDS.find(g => g.id === 'single')!, { x: 20, y: 30, w: 50, h: 40 }, 0)
|
||||
expect(els[0].frame).toMatchObject({ x: 20, y: 30, w: 50, h: 40 })
|
||||
})
|
||||
|
||||
it('never produces a frame too small to see, even from a tiny box', () => {
|
||||
const els = gridElements(GRIDS.find(g => g.id === 'grid-12')!, { x: 0, y: 0, w: 8, h: 8 }, 6)
|
||||
for (const el of els) {
|
||||
expect(el.frame.w).toBeGreaterThanOrEqual(2)
|
||||
expect(el.frame.h).toBeGreaterThanOrEqual(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries the corner radius onto every frame', () => {
|
||||
const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 3, 5)
|
||||
for (const el of els) expect(el.kind === 'photo' && el.radius).toBe(5)
|
||||
})
|
||||
|
||||
it('produces frames for every grid in the catalogue without a bad number', () => {
|
||||
for (const grid of GRIDS) {
|
||||
for (const el of gridElements(grid, box, 3)) {
|
||||
for (const v of [el.frame.x, el.frame.y, el.frame.w, el.frame.h]) {
|
||||
expect(Number.isFinite(v), grid.id).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultGridBox', () => {
|
||||
it('spans both pages of a spread', () => {
|
||||
const b = defaultGridBox(page, false)
|
||||
expect(b.x + b.w).toBeCloseTo(page.pageWidth * 2 - b.x, 6)
|
||||
expect(b.w).toBeGreaterThan(page.pageWidth)
|
||||
})
|
||||
|
||||
it('stays on one page for a cover', () => {
|
||||
const b = defaultGridBox(page, true)
|
||||
expect(b.x + b.w).toBeLessThanOrEqual(page.pageWidth)
|
||||
})
|
||||
|
||||
it('keeps clear of the safe area rather than landing on it', () => {
|
||||
const b = defaultGridBox(page, true)
|
||||
expect(b.x).toBeGreaterThanOrEqual(page.safe)
|
||||
expect(b.y).toBeGreaterThanOrEqual(page.safe)
|
||||
})
|
||||
|
||||
it('is symmetric, so a grid dropped into it looks centred', () => {
|
||||
const b = defaultGridBox(page, false)
|
||||
expect(b.x).toBeCloseTo(page.pageWidth * 2 - (b.x + b.w), 6)
|
||||
expect(b.y).toBeCloseTo(page.pageHeight - (b.y + b.h), 6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* PRESETS-001
|
||||
*
|
||||
* The format sizes live in two places: the Studio's picker here, and shared,
|
||||
* where the assistant's create_book lays a new book down. This holds them to
|
||||
* the same millimetres, so a book made either way has the same trim.
|
||||
*/
|
||||
import { BOOK_PAGE_PRESET_SIZES } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PAGE_PRESETS } from '../../../src/components/Studio/pagePresets';
|
||||
|
||||
describe('page format sizes', () => {
|
||||
it('PRESETS-001: match the shared table a new book is laid down with, format for format', () => {
|
||||
const fromStudio: Record<string, object> = {};
|
||||
for (const p of Object.values(PAGE_PRESETS)) {
|
||||
fromStudio[p.id] = { pageWidth: p.pageWidthMm, pageHeight: p.pageHeightMm, bleed: p.bleedMm, safe: p.safeMm };
|
||||
}
|
||||
expect(BOOK_PAGE_PRESET_SIZES).toEqual(fromStudio);
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,10 @@ import type { BookDocument, BookElement, BookPageSetup, BookSpread } from '@trek
|
||||
import {
|
||||
MAX_BOOK_LAYOUTS, bookBadgeElementSchema, bookLayoutSchema, bookPageSetupSchema, bookPhotoElementSchema,
|
||||
bookShapeElementSchema, bookTextElementSchema,
|
||||
applySavedBookLayout as applySavedLayout, bookLayoutFromSpread as layoutFromSpread,
|
||||
bookLayoutPreview as layoutPreview, bookLayoutRole as layoutRole, nextBookLayoutName as nextLayoutName,
|
||||
} from '@trek/shared'
|
||||
import { pastedElements } from '../../../src/components/Studio/studioClipboard'
|
||||
import {
|
||||
applySavedLayout, layoutFromSpread, layoutPreview, layoutRole, nextLayoutName,
|
||||
} from '../../../src/components/Studio/savedLayouts'
|
||||
import { useStudioStore } from '../../../src/store/studioStore'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { BookPageSetup } from '@trek/shared'
|
||||
import { bookPageSetupSchema } from '@trek/shared'
|
||||
import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from '../../../src/components/Studio/templates'
|
||||
|
||||
/**
|
||||
* Layouts (#1973).
|
||||
*
|
||||
* The cover set exists because a cover is a different problem from a spread:
|
||||
* one page, type as the subject rather than as a caption. A spread layout
|
||||
* applied to it lands half its frames past the edge of the page, which is why
|
||||
* the two sets are kept apart rather than filtered out of one.
|
||||
*/
|
||||
|
||||
const page: BookPageSetup = bookPageSetupSchema.parse({
|
||||
preset: 'square-210', pageWidth: 210, pageHeight: 210,
|
||||
})
|
||||
|
||||
describe('the layout catalogue', () => {
|
||||
it('gives every layout a unique id across both sets', () => {
|
||||
const ids = [...TEMPLATES, ...COVER_TEMPLATES].map(t => t.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('gives every layout at least one slot', () => {
|
||||
for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) {
|
||||
expect(tpl.build(page).length, tpl.id).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('counts its photo slots honestly', () => {
|
||||
for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) {
|
||||
const drawn = tpl.build(page).filter(s => s.kind === 'photo').length
|
||||
expect(drawn, tpl.id).toBe(tpl.photoSlots)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every slot a frame with a positive size', () => {
|
||||
for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) {
|
||||
for (const slot of tpl.build(page)) {
|
||||
expect(slot.frame.w, tpl.id).toBeGreaterThan(0)
|
||||
expect(slot.frame.h, tpl.id).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('the cover set', () => {
|
||||
it('is marked as single-page throughout', () => {
|
||||
for (const tpl of COVER_TEMPLATES) {
|
||||
expect(tpl.role, tpl.id).toBe('single')
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* The rule that makes them a separate set: everything stays on one page.
|
||||
* A frame reaching past pageWidth would be half in the fold of a book that
|
||||
* has no fold there.
|
||||
*/
|
||||
it('keeps every frame on the one page', () => {
|
||||
for (const tpl of COVER_TEMPLATES) {
|
||||
for (const slot of tpl.build(page)) {
|
||||
expect(slot.frame.x, `${tpl.id} x`).toBeGreaterThanOrEqual(-page.bleed - 0.01)
|
||||
expect(slot.frame.x + slot.frame.w, `${tpl.id} right`)
|
||||
.toBeLessThanOrEqual(page.pageWidth + page.bleed + 0.01)
|
||||
expect(slot.frame.y + slot.frame.h, `${tpl.id} bottom`)
|
||||
.toBeLessThanOrEqual(page.pageHeight + page.bleed + 0.01)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('always has somewhere for the title', () => {
|
||||
for (const tpl of COVER_TEMPLATES) {
|
||||
expect(tpl.build(page).some(s => s.kind === 'heading'), tpl.id).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('includes one that needs no photograph at all', () => {
|
||||
expect(COVER_TEMPLATES.some(t => t.photoSlots === 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the spread set', () => {
|
||||
it('is not marked single, so the panel keeps the two apart', () => {
|
||||
for (const tpl of TEMPLATES) {
|
||||
expect(tpl.role ?? 'inner', tpl.id).toBe('inner')
|
||||
}
|
||||
})
|
||||
|
||||
it('stays within the spread', () => {
|
||||
for (const tpl of TEMPLATES) {
|
||||
for (const slot of tpl.build(page)) {
|
||||
expect(slot.frame.x + slot.frame.w, `${tpl.id} right`)
|
||||
.toBeLessThanOrEqual(page.pageWidth * 2 + page.bleed + 0.01)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('offers a range of photo counts rather than five versions of one idea', () => {
|
||||
const counts = new Set(TEMPLATES.map(t => t.photoSlots))
|
||||
expect(counts.size).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applying a cover layout', () => {
|
||||
const spread = {
|
||||
id: 'c1', role: 'cover' as const, background: null, parked: [], entryId: null,
|
||||
elements: [
|
||||
{
|
||||
id: 'p1', kind: 'photo' as const, frame: { x: 0, y: 0, w: 50, h: 50 },
|
||||
rotation: 0, opacity: 1, locked: false, photoId: 42, fit: 'cover' as const,
|
||||
focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none' as const,
|
||||
mask: null, frameStyle: 'none' as const,
|
||||
},
|
||||
{
|
||||
id: 't1', kind: 'text' as const, frame: { x: 0, y: 60, w: 100, h: 20 },
|
||||
rotation: 0, opacity: 1, locked: false, text: 'Iceland', font: 'sans' as const,
|
||||
size: 30, weight: 700 as const, italic: false, align: 'left' as const,
|
||||
leading: 1.1, tracking: 0, color: '#111111', binding: null, overridden: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('pours the existing picture and title into the new arrangement', () => {
|
||||
const next = applyTemplate(spread, COVER_TEMPLATES[0], page)
|
||||
const photo = next.elements.find(e => e.kind === 'photo')
|
||||
const text = next.elements.find(e => e.kind === 'text')
|
||||
expect(photo?.kind === 'photo' && photo.photoId).toBe(42)
|
||||
expect(text?.kind === 'text' && text.text).toBe('Iceland')
|
||||
})
|
||||
|
||||
/*
|
||||
* Trying the type-only cover must not cost the photograph — it comes back
|
||||
* the moment a layout with a frame is applied.
|
||||
*/
|
||||
it('parks what a type-only cover has no room for', () => {
|
||||
const quiet = COVER_TEMPLATES.find(t => t.photoSlots === 0)!
|
||||
const next = applyTemplate(spread, quiet, page)
|
||||
expect(next.elements.some(e => e.kind === 'photo')).toBe(false)
|
||||
expect(next.parked.some(e => e.kind === 'photo' && e.photoId === 42)).toBe(true)
|
||||
})
|
||||
|
||||
it('brings the parked picture back on a layout that has room', () => {
|
||||
const quiet = COVER_TEMPLATES.find(t => t.photoSlots === 0)!
|
||||
const parked = applyTemplate(spread, quiet, page)
|
||||
const back = applyTemplate(parked, COVER_TEMPLATES[0], page)
|
||||
const photo = back.elements.find(e => e.kind === 'photo')
|
||||
expect(photo?.kind === 'photo' && photo.photoId).toBe(42)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user