phone layout: text wraps the traced portrait outline; status lines no longer sit under the composer
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m59s
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m59s
shape-outside: url() sizes the image at its natural size and anchors it at the margin box, which the phone layout's -82px top margin moves down by the overhang, so the exclusion was the wrong size and in the wrong place and long-post words ran under the art. PoseAvatar now traces the displayed art's alpha (0.35, as before) into a polygon in border-box pixels, retraced on load and resize. The pose status lines sat in a shrinkable flex box (12px high, three 18px lines) and overflowed into the composer; the box is shrink="No". Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fb4eabbf38
commit
636cad9655
5 changed files with 127 additions and 6 deletions
|
|
@ -266,7 +266,6 @@ export const PoseButton = style({
|
|||
height: 'auto',
|
||||
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
|
||||
shapeMargin: toRem(7),
|
||||
shapeImageThreshold: 0.35,
|
||||
WebkitMaskImage: 'linear-gradient(#000 84%, transparent)',
|
||||
maskImage: 'linear-gradient(#000 84%, transparent)',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from '../layout/layout.css';
|
||||
import { poseSrc } from './poses';
|
||||
import { silhouette } from './silhouette';
|
||||
|
||||
type PoseAvatarProps = {
|
||||
mind: string;
|
||||
|
|
@ -16,17 +17,48 @@ type PoseAvatarProps = {
|
|||
|
||||
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>();
|
||||
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return undefined;
|
||||
const trace = () => {
|
||||
if (!img.complete || img.naturalWidth === 0) return;
|
||||
try {
|
||||
setShape(silhouette(img, !alt));
|
||||
} catch {
|
||||
setShape(undefined);
|
||||
}
|
||||
};
|
||||
trace();
|
||||
img.addEventListener('load', trace);
|
||||
const resize = new ResizeObserver(trace);
|
||||
resize.observe(img);
|
||||
return () => {
|
||||
img.removeEventListener('load', trace);
|
||||
resize.disconnect();
|
||||
};
|
||||
}, [src, alt]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
|
||||
// On phones the bubble text wraps around the portrait's silhouette.
|
||||
style={{ shapeOutside: `url(${src})` }}
|
||||
style={{ shapeOutside: shape }}
|
||||
data-user-id={userId}
|
||||
data-pose={pose}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img className={css.PosePortrait} src={src} alt={`${mind}, ${pose}`} draggable={false} />
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={css.PosePortrait}
|
||||
src={src}
|
||||
alt={`${mind}, ${pose}`}
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
56
src/app/components/message/pose/silhouette.test.ts
Normal file
56
src/app/components/message/pose/silhouette.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { silhouette } from './silhouette';
|
||||
|
||||
// A w x h image whose opaque pixels are the given columns [from, to) on each row.
|
||||
const fakeImage = (w: number, h: number, solid: (y: number) => [number, number] | null) => {
|
||||
const data = new Uint8ClampedArray(w * h * 4);
|
||||
for (let y = 0; y < h; y += 1) {
|
||||
const span = solid(y);
|
||||
if (span) for (let x = span[0]; x < span[1]; x += 1) data[(y * w + x) * 4 + 3] = 255;
|
||||
}
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({ canvas, drawImage: vi.fn(), getImageData: () => ({ data }) }),
|
||||
};
|
||||
vi.stubGlobal('document', { createElement: () => canvas });
|
||||
return { clientWidth: w, clientHeight: h } as HTMLImageElement;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('silhouette', () => {
|
||||
// Art in columns 20..60 from row 8 down; rows 0..7 are empty.
|
||||
const art = (y: number): [number, number] | null => (y < 8 ? null : [20, 60]);
|
||||
|
||||
it('hugs the right edge of the art when the text sits to its right', () => {
|
||||
const poly = silhouette(fakeImage(100, 16, art), true);
|
||||
expect(poly).toBe(
|
||||
'polygon(0px 0, 0px 0px, 0px 4px, 60px 8px, 60px 12px, 60px 16px, 0px 16px) border-box'
|
||||
);
|
||||
});
|
||||
|
||||
it('hugs the left edge of the art when the text sits to its left', () => {
|
||||
const poly = silhouette(fakeImage(100, 16, art), false);
|
||||
expect(poly).toBe(
|
||||
'polygon(100px 0, 100px 0px, 100px 4px, 20px 8px, 20px 12px, 20px 16px, 100px 16px) border-box'
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the widest row of a band, so no row of the art pokes out of the polygon', () => {
|
||||
// Row 5 reaches x=90; its band's edge row (y=8 covers rows 4..8) must include it.
|
||||
const poly = silhouette(
|
||||
fakeImage(100, 16, (y) => (y === 5 ? [20, 90] : null)),
|
||||
true
|
||||
);
|
||||
expect(poly).toContain('90px 8px');
|
||||
});
|
||||
|
||||
it('has no shape before the image has a size or where there is no canvas', () => {
|
||||
expect(
|
||||
silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
34
src/app/components/message/pose/silhouette.ts
Normal file
34
src/app/components/message/pose/silhouette.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
const SOLID = 0.35 * 255;
|
||||
const ROW = 4;
|
||||
|
||||
// The edge of the portrait that the text wraps against, as a polygon in border-box pixels.
|
||||
// `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);
|
||||
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);
|
||||
const { data } = ctx.getImageData(0, 0, w, h);
|
||||
const outer = textOnRight ? 0 : w;
|
||||
const edge: string[] = [];
|
||||
for (let y = 0; y <= h; y += ROW) {
|
||||
let x = outer;
|
||||
// A band's edge is the extreme solid pixel over its rows, so no row pokes out of the polygon.
|
||||
for (let r = Math.max(0, y - ROW); r < Math.min(h, y + 1); r += 1) {
|
||||
for (let i = 0; i < w; i += 1) {
|
||||
const px = textOnRight ? w - 1 - i : i;
|
||||
if (data[(r * w + px) * 4 + 3] > SOLID) {
|
||||
x = textOnRight ? Math.max(x, px + 1) : Math.min(x, px);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
edge.push(`${x}px ${y}px`);
|
||||
}
|
||||
return `polygon(${outer}px 0, ${edge.join(', ')}, ${outer}px ${h}px) border-box`;
|
||||
};
|
||||
|
|
@ -42,7 +42,7 @@ export function RoomViewPoseStatus({ room }: { room: Room }) {
|
|||
if (statuses.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ padding: '0 1rem' }}>
|
||||
<Box direction="Column" shrink="No" style={{ padding: '0 1rem' }}>
|
||||
{statuses.map((s) => (
|
||||
<Text key={s.mind} size="T200" priority="300">
|
||||
{poseStatusLine(s)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue