Poses nest into the chat bubble corner (desktop and phone) #72
10 changed files with 325 additions and 198 deletions
|
|
@ -30,10 +30,8 @@ type BubbleLayoutProps = {
|
|||
hideBubble?: boolean;
|
||||
/** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */
|
||||
own?: boolean;
|
||||
/** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */
|
||||
/** A Mind's pose portrait; it replaces the avatar and nests in a corner of the bubble. */
|
||||
pose?: ReactNode;
|
||||
/** On phones the portrait floats on the other side. */
|
||||
alt?: boolean;
|
||||
/** Delay in ms before the first-appearance animation; undefined plays none. */
|
||||
enterDelay?: number;
|
||||
before?: ReactNode;
|
||||
|
|
@ -41,7 +39,7 @@ type BubbleLayoutProps = {
|
|||
};
|
||||
|
||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||
({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => {
|
||||
({ hideBubble, own, pose, enterDelay, before, header, children, style, ...props }, ref) => {
|
||||
// `false` is a Mind's message continuing a run: aligned with the portraits, showing none.
|
||||
const poseRow = pose !== undefined;
|
||||
const arrow = !!before && !poseRow && !own;
|
||||
|
|
@ -50,12 +48,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
|||
<Box
|
||||
gap="300"
|
||||
{...props}
|
||||
className={classNames(
|
||||
poseRow && css.BubbleRowPose,
|
||||
pose && css.BubbleRowPortrait,
|
||||
own && css.BubbleRowOwn,
|
||||
props.className
|
||||
)}
|
||||
className={classNames(own && css.BubbleRowOwn, props.className)}
|
||||
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
|
||||
ref={ref}
|
||||
>
|
||||
|
|
@ -67,19 +60,9 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
|||
<Box
|
||||
grow={own ? 'No' : 'Yes'}
|
||||
direction="Column"
|
||||
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
|
||||
className={classNames(own && css.BubbleColumnOwn)}
|
||||
>
|
||||
{header && (
|
||||
<div
|
||||
className={classNames(
|
||||
pose && css.BubbleHeaderPose,
|
||||
pose && alt && css.BubbleHeaderPoseAlt,
|
||||
own && css.BubbleHeaderOwn
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
|
||||
{hideBubble ? (
|
||||
children
|
||||
) : (
|
||||
|
|
@ -89,6 +72,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
|||
css.BubbleContent,
|
||||
arrow && css.BubbleContentArrowLeft,
|
||||
poseRow && css.BubbleContentPose,
|
||||
pose && css.BubbleContentPortrait,
|
||||
own && css.BubbleContentOwn,
|
||||
entering && css.EnterBubble
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -152,39 +152,21 @@ export const BubbleContentArrowLeft = style({
|
|||
borderTopLeftRadius: 0,
|
||||
});
|
||||
|
||||
// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits
|
||||
// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top
|
||||
// edge, and the text wraps around its silhouette (shape-outside on the alpha channel).
|
||||
// A Mind's pose nests in the corner of its bubble, on phones and wide screens alike: the sprite's
|
||||
// body sits in the bubble's padding and the bubble's edge cuts it, so nothing hangs outside; the
|
||||
// text wraps around its silhouette (shape-outside, traced from the alpha channel) and the bubble
|
||||
// grows to hold it. How much of the art the edge may cut comes from pose/nest.ts.
|
||||
// Displayed width of the whole sprite image; the crop fractions are multiples of it.
|
||||
export const portraitWidth = createVar();
|
||||
const PHONE = 'screen and (max-width: 700px)';
|
||||
const PORTRAIT = toRem(190);
|
||||
const PHONE_PORTRAIT = 'min(38vw, 160px)';
|
||||
const PHONE_TEXT_FLOOR = toRem(170);
|
||||
const PORTRAIT_OVERHANG = 58;
|
||||
const PORTRAIT = toRem(124);
|
||||
const POSE_GUTTER = toRem(14);
|
||||
const BUBBLE_PAD = toRem(12);
|
||||
const BUBBLE_CORNER = toRem(6);
|
||||
const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)';
|
||||
const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)';
|
||||
|
||||
export const BubbleRowPose = style({
|
||||
position: 'relative',
|
||||
paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// A row that shows a portrait: room for it to hang above the bubble on phones.
|
||||
export const BubbleRowPortrait = style({
|
||||
minHeight: PORTRAIT,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
minHeight: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleRowOwn = style({
|
||||
justifyContent: 'flex-end',
|
||||
});
|
||||
|
|
@ -198,66 +180,31 @@ export const BubbleHeaderOwn = style({
|
|||
alignSelf: 'flex-end',
|
||||
});
|
||||
|
||||
// Phone: name and time are one plain line in flow above the bubble, on the side opposite the
|
||||
// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name
|
||||
// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by
|
||||
// the column: a short bubble's column is narrower than its own name, and a percentage cap there
|
||||
// truncated names to one letter.
|
||||
export const BubbleHeaderPose = style({
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
alignSelf: 'flex-end',
|
||||
// The band above the bubble where the portrait overhangs: the header sits at its foot, beside
|
||||
// the portrait, so the overhang needs no margin of its own.
|
||||
minHeight: toRem(PORTRAIT_OVERHANG),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
margin: `0 ${toRem(14)} ${toRem(2)}`,
|
||||
width: 'max-content',
|
||||
maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`,
|
||||
textAlign: 'right',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleHeaderPoseAlt = style({
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
alignSelf: 'flex-start',
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin.
|
||||
globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
marginTop: toRem(PORTRAIT_OVERHANG),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleColumnPose = style({
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
// The message body is a flex column, which forms its own block formatting context and would sit
|
||||
// whole beside the floated portrait; as a plain block its lines wrap around the silhouette and
|
||||
// then run the bubble's full width below it.
|
||||
export const BubbleContentPose = style({
|
||||
borderRadius: config.radii.R500,
|
||||
borderTopLeftRadius: BUBBLE_CORNER,
|
||||
display: 'flow-root',
|
||||
overflow: 'clip',
|
||||
padding: BUBBLE_PAD,
|
||||
});
|
||||
|
||||
// A bubble that shows a portrait is at least portrait + a readable column wide, or the text beside
|
||||
// it collapses to a strip.
|
||||
export const BubbleContentPortrait = style({
|
||||
vars: { [portraitWidth]: PORTRAIT },
|
||||
minWidth: `min(100%, calc(${portraitWidth} + ${PHONE_TEXT_FLOOR}))`,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
display: 'flow-root',
|
||||
// A bubble with a portrait is at least portrait + a readable column wide, or the text
|
||||
// beside the portrait collapses to a strip.
|
||||
minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`,
|
||||
borderTopLeftRadius: config.radii.R500,
|
||||
padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`,
|
||||
vars: { [portraitWidth]: PHONE_PORTRAIT },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${BubbleContentPose} > div`, {
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
export const BubbleContentOwn = style({
|
||||
backgroundColor: color.Primary.Container,
|
||||
color: color.Primary.OnContainer,
|
||||
|
|
@ -266,51 +213,37 @@ export const BubbleContentOwn = style({
|
|||
borderTopRightRadius: BUBBLE_CORNER,
|
||||
});
|
||||
|
||||
// The button is the visible part of the sprite (the part inside the bubble's corner); the image
|
||||
// inside it is offset by the crop. The inline --pose-* fractions (of the image width) come from
|
||||
// nestView; until the art's size is known the button is simply the whole image.
|
||||
export const PoseButton = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`,
|
||||
width: PORTRAIT,
|
||||
height: PORTRAIT,
|
||||
float: 'left',
|
||||
width: `var(--pose-bw, ${portraitWidth})`,
|
||||
height: 'var(--pose-bh, auto)',
|
||||
// Out into the padding on the top, bottom and outer side, so the bubble's edge is the crop.
|
||||
margin: `calc(-1 * ${BUBBLE_PAD}) ${toRem(6)} calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD})`,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 0,
|
||||
overflow: 'hidden',
|
||||
shapeMargin: toRem(7),
|
||||
transformOrigin: '50% 90%',
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
position: 'static',
|
||||
float: 'left',
|
||||
width: PHONE_PORTRAIT,
|
||||
height: 'auto',
|
||||
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
|
||||
shapeMargin: toRem(7),
|
||||
WebkitMaskImage: 'linear-gradient(#000 84%, transparent)',
|
||||
maskImage: 'linear-gradient(#000 84%, transparent)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PoseButtonAlt = style({
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
float: 'right',
|
||||
margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`,
|
||||
},
|
||||
},
|
||||
float: 'right',
|
||||
margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`,
|
||||
});
|
||||
|
||||
export const PosePortrait = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'top',
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
height: 'auto',
|
||||
},
|
||||
},
|
||||
display: 'block',
|
||||
width: portraitWidth,
|
||||
maxWidth: 'none',
|
||||
height: 'auto',
|
||||
marginLeft: 'var(--pose-ix, 0)',
|
||||
marginTop: 'var(--pose-iy, 0)',
|
||||
});
|
||||
|
||||
const pop = keyframes({
|
||||
|
|
@ -395,61 +328,12 @@ export const MessageTextBody = recipe({
|
|||
|
||||
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
|
||||
|
||||
// The message content is a flex column, which forms its own block formatting context and so sits
|
||||
// whole beside the floated portrait: the text stays as narrow as the gap beside it for the
|
||||
// bubble's full height. As a plain block its lines wrap around the silhouette and then run the
|
||||
// bubble's full width below it.
|
||||
globalStyle(`${BubbleContentPose} > div`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The name wraps instead of ellipsising: the header already caps its own width, and Username's own
|
||||
// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…".
|
||||
globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'visible',
|
||||
textOverflow: 'clip',
|
||||
overflowWrap: 'break-word',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// A name too long for one line takes its own lines and the time drops below it, instead of the
|
||||
// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The
|
||||
// row hugs the side the header sits on, so a dropped time stays under the name's edge.
|
||||
globalStyle(`${BubbleHeaderPose} > div`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${BubbleHeaderPoseAlt} > div`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so
|
||||
// beside a left portrait the number lands under the art. Inside, it is a word on the first line
|
||||
// and wraps around the silhouette like the rest.
|
||||
globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, {
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
listStylePosition: 'inside',
|
||||
paddingLeft: 0,
|
||||
},
|
||||
},
|
||||
listStylePosition: 'inside',
|
||||
paddingLeft: 0,
|
||||
});
|
||||
|
||||
// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in
|
||||
|
|
|
|||
|
|
@ -1,53 +1,124 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from '../layout/layout.css';
|
||||
import { poseSrc } from './poses';
|
||||
import { poseSrc, safeBox } from './poses';
|
||||
import { alphaBounds, nestView, type Box } from './nest';
|
||||
import { silhouette } from './silhouette';
|
||||
|
||||
type PoseAvatarProps = {
|
||||
mind: string;
|
||||
pose: string;
|
||||
userId: string;
|
||||
/** Float on the right on phones. */
|
||||
/** Nest in the bubble's top-right corner instead of the top-left. */
|
||||
alt?: boolean;
|
||||
/** Play the first-appearance pop. */
|
||||
enter?: boolean;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
// Safe boxes derived from art's alpha, by source; the art is traced once.
|
||||
const traced = new Map<string, { box: Box; size: [number, number] }>();
|
||||
const trace = (img: HTMLImageElement, src: string) => {
|
||||
const hit = traced.get(src);
|
||||
if (hit) return hit;
|
||||
const w = img.naturalWidth;
|
||||
const h = img.naturalHeight;
|
||||
const ctx = document.createElement('canvas').getContext('2d');
|
||||
if (!ctx) return undefined;
|
||||
ctx.canvas.width = w;
|
||||
ctx.canvas.height = h;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const box = alphaBounds(ctx.getImageData(0, 0, w, h).data, w, h);
|
||||
if (!box) return undefined;
|
||||
const found = { box, size: [w, h] as [number, number] };
|
||||
traced.set(src, found);
|
||||
return found;
|
||||
};
|
||||
|
||||
export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) {
|
||||
const src = poseSrc(mind, pose, import.meta.env.BASE_URL);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [shape, setShape] = useState<string>();
|
||||
const measured = safeBox(mind, pose);
|
||||
const [derived, setDerived] = useState<{ box: Box; size: [number, number] }>();
|
||||
const art = measured ?? (derived?.size ? derived : undefined);
|
||||
// Measured boxes are cropped by the share Aqua's numbers allow; a box traced from the alpha is
|
||||
// already tight to the art, so the bubble may take all of the transparent margin.
|
||||
const view = art && nestView(art.box, art.size, !!alt, measured ? undefined : 1);
|
||||
|
||||
// On phones the bubble text wraps around the portrait's silhouette. Retraced when the art
|
||||
// loads and when the portrait's width changes with the viewport.
|
||||
// Art with no measured box: find its extent when it loads.
|
||||
useEffect(() => {
|
||||
setDerived(undefined);
|
||||
const img = imgRef.current;
|
||||
if (!img || measured) return undefined;
|
||||
const find = () => {
|
||||
if (!img.complete || img.naturalWidth === 0) return;
|
||||
try {
|
||||
setDerived(trace(img, src));
|
||||
} catch {
|
||||
setDerived(undefined);
|
||||
}
|
||||
};
|
||||
find();
|
||||
img.addEventListener('load', find);
|
||||
return () => img.removeEventListener('load', find);
|
||||
}, [src, measured]);
|
||||
|
||||
// The bubble text wraps around the portrait's silhouette. Retraced when the art loads, when its
|
||||
// crop is known, and when its width changes with the viewport.
|
||||
useEffect(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return undefined;
|
||||
const trace = () => {
|
||||
const retrace = () => {
|
||||
if (!img.complete || img.naturalWidth === 0) return;
|
||||
try {
|
||||
setShape(silhouette(img, !alt));
|
||||
const box = img.parentElement;
|
||||
const style = getComputedStyle(img);
|
||||
setShape(
|
||||
silhouette(
|
||||
img,
|
||||
!alt,
|
||||
box
|
||||
? {
|
||||
x: 0 - parseFloat(style.marginLeft || '0'),
|
||||
y: 0 - parseFloat(style.marginTop || '0'),
|
||||
width: box.clientWidth,
|
||||
height: box.clientHeight,
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
setShape(undefined);
|
||||
}
|
||||
};
|
||||
trace();
|
||||
img.addEventListener('load', trace);
|
||||
const resize = new ResizeObserver(trace);
|
||||
retrace();
|
||||
img.addEventListener('load', retrace);
|
||||
const resize = new ResizeObserver(retrace);
|
||||
resize.observe(img);
|
||||
return () => {
|
||||
img.removeEventListener('load', trace);
|
||||
img.removeEventListener('load', retrace);
|
||||
resize.disconnect();
|
||||
};
|
||||
}, [src, alt]);
|
||||
}, [src, alt, view?.x, view?.y, view?.width, view?.height]);
|
||||
|
||||
// Crop fractions are of the image's width, so one set serves every displayed size.
|
||||
const frac = (px: number) => `calc(${css.portraitWidth} * ${px / (art?.size[0] ?? 1)})`;
|
||||
const cropStyle =
|
||||
art && view
|
||||
? ({
|
||||
'--pose-bw': frac(view.width),
|
||||
'--pose-bh': frac(view.height),
|
||||
'--pose-ix': frac(-view.x),
|
||||
'--pose-iy': frac(-view.y),
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
|
||||
style={{ shapeOutside: shape }}
|
||||
style={{ ...cropStyle, shapeOutside: shape }}
|
||||
data-user-id={userId}
|
||||
data-pose={pose}
|
||||
onClick={onClick}
|
||||
|
|
|
|||
72
src/app/components/message/pose/nest.test.ts
Normal file
72
src/app/components/message/pose/nest.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { alphaBounds, nestView } from './nest';
|
||||
import boxes from './safeBoxes.json';
|
||||
|
||||
describe('nestView', () => {
|
||||
// Aqua neutral: 560x374, safe box [144,22,492,317] (right margin 68, top 22).
|
||||
it('crops half the right and top margins for a top-right nest', () => {
|
||||
expect(nestView([144, 22, 492, 317], [560, 374], true)).toEqual({
|
||||
x: 0,
|
||||
y: 11,
|
||||
width: 526,
|
||||
height: 363,
|
||||
});
|
||||
});
|
||||
|
||||
it('crops the left margin instead for a top-left nest', () => {
|
||||
expect(nestView([144, 22, 492, 317], [560, 374], false)).toEqual({
|
||||
x: 72,
|
||||
y: 11,
|
||||
width: 488,
|
||||
height: 363,
|
||||
});
|
||||
});
|
||||
|
||||
it('never crops the top of art already touching it (smug, headband at y=0)', () => {
|
||||
const v = nestView([140, 0, 487, 241], [560, 374], true);
|
||||
expect(v.y).toBe(0);
|
||||
expect(v.height).toBe(374);
|
||||
});
|
||||
|
||||
it('can take the whole margin', () => {
|
||||
expect(nestView([10, 4, 90, 50], [100, 60], true, 1)).toEqual({
|
||||
x: 0,
|
||||
y: 4,
|
||||
width: 90,
|
||||
height: 56,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps every measured safe box intact at the default share', () => {
|
||||
const aqua = boxes.aqua as Record<string, number[]>;
|
||||
expect(Object.keys(aqua)).toHaveLength(15);
|
||||
Object.values(aqua).forEach(([x0, y0, x1, y1, w, h]) => {
|
||||
const right = nestView([x0, y0, x1, y1], [w, h], true);
|
||||
expect(right.x).toBeLessThanOrEqual(x0);
|
||||
expect(right.x + right.width).toBeGreaterThanOrEqual(x1);
|
||||
expect(right.y).toBeLessThanOrEqual(y0);
|
||||
const left = nestView([x0, y0, x1, y1], [w, h], false);
|
||||
expect(left.x).toBeLessThanOrEqual(x0);
|
||||
expect(left.x + left.width).toBe(w);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('alphaBounds', () => {
|
||||
it('finds the opaque rectangle', () => {
|
||||
const w = 6;
|
||||
const h = 5;
|
||||
const data = new Uint8ClampedArray(w * h * 4);
|
||||
[
|
||||
[2, 1],
|
||||
[4, 3],
|
||||
].forEach(([x, y]) => {
|
||||
data[(y * w + x) * 4 + 3] = 255;
|
||||
});
|
||||
expect(alphaBounds(data, w, h)).toEqual([2, 1, 5, 4]);
|
||||
});
|
||||
|
||||
it('reports nothing for an empty image', () => {
|
||||
expect(alphaBounds(new Uint8ClampedArray(16), 2, 2)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
58
src/app/components/message/pose/nest.ts
Normal file
58
src/app/components/message/pose/nest.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// A pose nests into the corner of its bubble: the sprite's body sits in the bubble's padding and is
|
||||
// cut off by the bubble's edge, so nothing hangs outside it.
|
||||
|
||||
/** [x0, y0, x1, y1] in image pixels, origin top-left, x1/y1 exclusive. */
|
||||
export type Box = [number, number, number, number];
|
||||
|
||||
// Share of the room between the safe box and the image edge that the bubble edge may cut away.
|
||||
// Aqua's measurement says all of it can go; half leaves the outline a little air.
|
||||
export const CROP_SHARE = 0.5;
|
||||
|
||||
export type NestView = {
|
||||
/** The visible region of the image, in pixels. */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// The part of the image that shows, for a sprite in the bubble's top-right (`right`) or top-left
|
||||
// corner. Only the corner's two sides are cropped: the side towards the bubble's edge and the top.
|
||||
// The far side and the bottom stay whole; the bubble's bottom edge cuts the bust.
|
||||
export const nestView = (
|
||||
safe: Box,
|
||||
size: [number, number],
|
||||
right: boolean,
|
||||
share = CROP_SHARE
|
||||
): NestView => {
|
||||
const [w, h] = size;
|
||||
const [x0, y0, x1] = safe;
|
||||
const side = Math.round(share * (right ? w - x1 : x0));
|
||||
const top = Math.round(share * y0);
|
||||
const x = right ? 0 : side;
|
||||
return { x, y: top, width: w - side, height: h - top };
|
||||
};
|
||||
|
||||
// Bounds of the pixels whose alpha exceeds `min`, as a safe box for art with no measured one.
|
||||
export const alphaBounds = (
|
||||
data: ArrayLike<number>,
|
||||
w: number,
|
||||
h: number,
|
||||
min = 8
|
||||
): Box | undefined => {
|
||||
let x0 = w;
|
||||
let y0 = h;
|
||||
let x1 = 0;
|
||||
let y1 = 0;
|
||||
for (let y = 0; y < h; y += 1) {
|
||||
for (let x = 0; x < w; x += 1) {
|
||||
if (data[(y * w + x) * 4 + 3] > min) {
|
||||
x0 = Math.min(x0, x);
|
||||
x1 = Math.max(x1, x + 1);
|
||||
y0 = Math.min(y0, y);
|
||||
y1 = y + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return x1 > x0 ? [x0, y0, x1, y1] : undefined;
|
||||
};
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import catalogue from './catalogue.json';
|
||||
import safeBoxes from './safeBoxes.json';
|
||||
import type { Box } from './nest';
|
||||
|
||||
// Pose labels travel in the event content under POSE_KEY. The catalogue
|
||||
// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only
|
||||
|
|
@ -40,3 +42,14 @@ export const resolvePose = (mind: string, label: unknown): string => {
|
|||
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
|
||||
`${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`;
|
||||
|
||||
// Safe-crop boxes measured from the art's pixels (Aqua, `aqua-pose-crop-boxes-20261110`): the
|
||||
// head and headphones plus outline, which the bubble's edge must not cut. [x0, y0, x1, y1, width,
|
||||
// height] in image pixels. Art with no entry gets its box from the sprite's alpha at load time.
|
||||
const boxes = safeBoxes as Record<string, Record<string, number[]>>;
|
||||
export const safeBox = (
|
||||
mind: string,
|
||||
pose: string
|
||||
): { box: Box; size: [number, number] } | undefined => {
|
||||
const b = Object.prototype.hasOwnProperty.call(boxes, mind) ? boxes[mind][pose] : undefined;
|
||||
return b ? { box: [b[0], b[1], b[2], b[3]], size: [b[4], b[5]] } : undefined;
|
||||
};
|
||||
|
|
|
|||
19
src/app/components/message/pose/safeBoxes.json
Normal file
19
src/app/components/message/pose/safeBoxes.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"aqua": {
|
||||
"neutral": [144, 22, 492, 317, 560, 374],
|
||||
"happy": [157, 22, 506, 317, 574, 374],
|
||||
"amused": [154, 22, 500, 317, 562, 374],
|
||||
"thinking": [154, 16, 500, 307, 562, 374],
|
||||
"annoyed": [154, 4, 499, 282, 562, 374],
|
||||
"surprised": [141, 5, 485, 282, 560, 374],
|
||||
"concerned": [156, 4, 501, 283, 574, 374],
|
||||
"fretful": [156, 16, 503, 305, 574, 374],
|
||||
"weary": [161, 16, 509, 307, 624, 374],
|
||||
"glare": [160, 5, 508, 282, 624, 374],
|
||||
"smug": [140, 0, 487, 241, 560, 374],
|
||||
"sigh": [154, 0, 500, 241, 574, 374],
|
||||
"charging": [152, 0, 497, 241, 562, 374],
|
||||
"delighted": [159, 0, 507, 241, 624, 374],
|
||||
"content": [144, 16, 492, 306, 560, 374]
|
||||
}
|
||||
}
|
||||
|
|
@ -53,4 +53,22 @@ describe('silhouette', () => {
|
|||
silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('traces only the visible part of a cropped portrait, drawn at its offset', () => {
|
||||
const drawImage = vi.fn();
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({
|
||||
canvas,
|
||||
drawImage,
|
||||
getImageData: () => ({ data: new Uint8ClampedArray(40 * 8 * 4) }),
|
||||
}),
|
||||
};
|
||||
vi.stubGlobal('document', { createElement: () => canvas });
|
||||
const img = { clientWidth: 100, clientHeight: 20 } as HTMLImageElement;
|
||||
silhouette(img, true, { x: 0, y: 12, width: 40, height: 8 });
|
||||
expect([canvas.width, canvas.height]).toEqual([40, 8]);
|
||||
expect(drawImage).toHaveBeenCalledWith(img, 0, -12, 100, 20);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,14 +5,23 @@ const ROW = 4;
|
|||
// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and
|
||||
// anchors it at the margin box, which the phone layout's negative top margin shifts down by the
|
||||
// overhang, so the exclusion neither matches the art's size nor its place.
|
||||
export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => {
|
||||
const w = Math.round(img.clientWidth);
|
||||
const h = Math.round(img.clientHeight);
|
||||
//
|
||||
// `view` is the part of the displayed image that shows (the button's box, in displayed pixels):
|
||||
// the portrait is cropped by the bubble, so the polygon is traced over what is left of the art.
|
||||
export type View = { x: number; y: number; width: number; height: number };
|
||||
|
||||
export const silhouette = (
|
||||
img: HTMLImageElement,
|
||||
textOnRight: boolean,
|
||||
view?: View
|
||||
): string | undefined => {
|
||||
const w = Math.round(view?.width ?? img.clientWidth);
|
||||
const h = Math.round(view?.height ?? img.clientHeight);
|
||||
const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null;
|
||||
if (!ctx) return undefined;
|
||||
ctx.canvas.width = w;
|
||||
ctx.canvas.height = h;
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
ctx.drawImage(img, 0 - (view?.x ?? 0), 0 - (view?.y ?? 0), img.clientWidth, img.clientHeight);
|
||||
const { data } = ctx.getImageData(0, 0, w, h);
|
||||
const outer = textOnRight ? 0 : w;
|
||||
const edge: string[] = [];
|
||||
|
|
|
|||
|
|
@ -1164,7 +1164,6 @@ export const Message = as<'div', MessageProps>(
|
|||
<BubbleLayout
|
||||
own={own}
|
||||
pose={mind ? poseJSX : undefined}
|
||||
alt={poseAlt}
|
||||
enterDelay={enterDelay}
|
||||
before={avatarJSX}
|
||||
header={headerJSX}
|
||||
|
|
|
|||
Loading…
Reference in a new issue