Sync letter improvements from lief
- Make LetterContent.body optional; a letter requires a non-empty body or at least one sticker - Replace colors:[]/flatMode with event-stripping: flat color moment = event field stripped from Stationery, removing the colors? field - Remove edgeScale: stickers render at their stored scale value with no edge-proximity size reduction, matching lief and the NIP - Fix sticker shrinking near card edges: add max-width:none to override Tailwind preflight max-width:100% on img/svg elements
This commit is contained in:
@@ -14,47 +14,10 @@ import { sanitizeSvg } from '@/lib/sanitizeSvg';
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 4;
|
||||
const BASE_SIZE_CQW = 14;
|
||||
const BASE_SIZE_CQW = 14; // cqw — base sticker size at scale=1, scales with card width
|
||||
/** Inward buffer (%) so sticker centers can't reach the very edge of the card. */
|
||||
const EDGE_BUFFER = 5;
|
||||
|
||||
/** Aspect ratio of the letter card (width / height). */
|
||||
const CARD_ASPECT = 5 / 4;
|
||||
|
||||
/**
|
||||
* Compute an edge-proximity scale factor so stickers shrink as their center
|
||||
* approaches any edge of the card. The sticker's nominal half-size (in %)
|
||||
* is compared against the distance from the center to the nearest edge;
|
||||
* when the distance is less than the half-size the sticker shrinks
|
||||
* proportionally so it always fits within the card boundary.
|
||||
*
|
||||
* Returns a multiplier in (0, 1] that should be applied on top of the
|
||||
* sticker's own `scale` value.
|
||||
*/
|
||||
function edgeScale(x: number, y: number, baseScale: number): number {
|
||||
// Half-size of the sticker in percentage of container width
|
||||
const halfW = (BASE_SIZE_CQW * baseScale) / 2; // in cqw, which == % of width
|
||||
// Container height in cqw units = 100 / aspectRatio
|
||||
const containerHeightCqw = 100 / CARD_ASPECT; // 80 for 5:4
|
||||
// Half-size as % of container height
|
||||
const halfH = (BASE_SIZE_CQW * baseScale) / 2 / containerHeightCqw * 100;
|
||||
|
||||
// Distance from center to each edge (in %)
|
||||
const dLeft = x;
|
||||
const dRight = 100 - x;
|
||||
const dTop = y;
|
||||
const dBottom = 100 - y;
|
||||
|
||||
// How much room we need vs how much we have, per axis
|
||||
const fitX = Math.min(dLeft, dRight) / halfW;
|
||||
const fitY = Math.min(dTop, dBottom) / halfH;
|
||||
|
||||
// Take the tightest constraint, clamp to (0, 1]
|
||||
const fit = Math.min(fitX, fitY, 1);
|
||||
// Floor at a tiny size so stickers don't vanish completely
|
||||
return Math.max(fit, 0.15);
|
||||
}
|
||||
|
||||
function isSafeUrl(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).protocol === 'https:';
|
||||
@@ -67,7 +30,7 @@ function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker;
|
||||
if (sticker.svg) {
|
||||
return (
|
||||
<div
|
||||
style={{ width: sizeCqw, height: sizeCqw }}
|
||||
style={{ width: sizeCqw, height: sizeCqw, maxWidth: 'none' }}
|
||||
className={`sticker-svg-wrap ${className ?? ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg) }}
|
||||
/>
|
||||
@@ -78,7 +41,7 @@ function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker;
|
||||
<img
|
||||
src={sticker.url}
|
||||
alt={sticker.shortcode}
|
||||
style={{ width: sizeCqw, height: sizeCqw }}
|
||||
style={{ width: sizeCqw, height: sizeCqw, maxWidth: 'none' }}
|
||||
className={className}
|
||||
draggable={false}
|
||||
/>
|
||||
@@ -87,8 +50,7 @@ function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker;
|
||||
|
||||
function StaticSticker({ sticker }: { sticker: LetterSticker }) {
|
||||
const s = sticker.scale ?? 1;
|
||||
const es = edgeScale(sticker.x, sticker.y, s);
|
||||
const sizeCqw = `${BASE_SIZE_CQW * s * es}cqw`;
|
||||
const sizeCqw = `${BASE_SIZE_CQW * s}cqw`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -125,8 +87,7 @@ function EditableSticker({
|
||||
containerRef,
|
||||
}: EditableStickerProps) {
|
||||
const s = sticker.scale ?? 1;
|
||||
const es = edgeScale(sticker.x, sticker.y, s);
|
||||
const sizeCqw = `${BASE_SIZE_CQW * s * es}cqw`;
|
||||
const sizeCqw = `${BASE_SIZE_CQW * s}cqw`;
|
||||
|
||||
const dragging = useRef(false);
|
||||
const hasMoved = useRef(false);
|
||||
|
||||
@@ -259,15 +259,23 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps)
|
||||
const hasEmoji = !!resolved?.emoji;
|
||||
const isColorMoment = selected?.event?.kind === COLOR_MOMENT_KIND;
|
||||
const isTheme = selected?.event?.kind === THEME_KIND;
|
||||
const isSingleColor = isColorMoment && selected?.colors !== undefined && selected.colors.length === 0;
|
||||
|
||||
// Remember the last color moment event so we can restore it when toggling off flat mode
|
||||
const lastColorMomentRef = useRef<NostrEvent | undefined>(undefined);
|
||||
if (isColorMoment && selected?.event) lastColorMomentRef.current = selected.event;
|
||||
|
||||
// Flat mode = color moment was selected but event has been stripped (only color remains)
|
||||
const isFlatMode = !!(selected && !selected.event && lastColorMomentRef.current);
|
||||
|
||||
const toggleSingleColor = () => {
|
||||
if (!selected || !isColorMoment) return;
|
||||
if (isSingleColor) {
|
||||
const { colors: _, ...rest } = selected;
|
||||
if (!selected) return;
|
||||
if (isFlatMode && lastColorMomentRef.current) {
|
||||
// Restore: put the event back
|
||||
onSelect({ ...selected, event: lastColorMomentRef.current });
|
||||
} else if (isColorMoment && selected.event) {
|
||||
// Flatten: strip the event, keep only color
|
||||
const { event: _, ...rest } = selected;
|
||||
onSelect(rest as Stationery);
|
||||
} else {
|
||||
onSelect({ ...selected, colors: [] });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -359,7 +367,7 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps)
|
||||
{tab === 'themes' && <ThemesGrid key={scope} selectedStationery={selected} onSelect={onSelect} authors={scopedAuthors} />}
|
||||
</div>
|
||||
|
||||
{((hasEmoji && !isTheme) || isColorMoment) && (
|
||||
{((hasEmoji && !isTheme) || isColorMoment || isFlatMode) && (
|
||||
<div className="flex items-center gap-4 px-1 pt-1">
|
||||
{hasEmoji && !isTheme && (
|
||||
<label className="flex items-center gap-1.5">
|
||||
@@ -367,9 +375,9 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps)
|
||||
<span className="text-sm text-muted-foreground font-medium">emblem</span>
|
||||
</label>
|
||||
)}
|
||||
{isColorMoment && (
|
||||
{(isColorMoment || isFlatMode) && (
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch checked={isSingleColor} onCheckedChange={toggleSingleColor} />
|
||||
<Switch checked={isFlatMode} onCheckedChange={toggleSingleColor} />
|
||||
<span className="text-sm text-muted-foreground font-medium">flat</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
+6
-12
@@ -29,7 +29,8 @@ export interface LetterSticker {
|
||||
}
|
||||
|
||||
export interface LetterContent {
|
||||
body: string;
|
||||
/** Main letter text. Optional — a letter must have either a non-empty body or at least one sticker. */
|
||||
body?: string;
|
||||
closing?: string;
|
||||
signature?: string;
|
||||
/** Stickers placed on the letter card — stored in encrypted content for privacy */
|
||||
@@ -53,8 +54,7 @@ export interface Stationery {
|
||||
emoji?: string;
|
||||
/** Emoji display mode: 'tile' (faint repeating pattern) or 'emblem' (single large centered glyph). */
|
||||
emojiMode?: 'tile' | 'emblem';
|
||||
/** Palette colors override. Empty array = "flat" mode (suppress palette). */
|
||||
colors?: string[];
|
||||
|
||||
/** CSS font-family string (e.g. "Caveat, cursive"). Set from the sender's font choice. */
|
||||
fontFamily?: string;
|
||||
/** Frame style ID. */
|
||||
@@ -110,14 +110,9 @@ export function resolveStationery(s: Stationery): ResolvedStationery {
|
||||
const event = s.event;
|
||||
|
||||
if (event?.kind === COLOR_MOMENT_KIND) {
|
||||
// Colors override: empty array = flat mode, undefined = read from event
|
||||
if (s.colors !== undefined) {
|
||||
base.colors = s.colors.length > 0 ? s.colors : undefined;
|
||||
} else {
|
||||
const hexRe = /^#[0-9A-Fa-f]{6}$/;
|
||||
const eventColors = event.tags.filter(([n]) => n === 'c').map(([, c]) => c).filter((c) => hexRe.test(c));
|
||||
if (eventColors.length >= 2) base.colors = eventColors;
|
||||
}
|
||||
const hexRe = /^#[0-9A-Fa-f]{6}$/;
|
||||
const eventColors = event.tags.filter(([n]) => n === 'c').map(([, c]) => c).filter((c) => hexRe.test(c));
|
||||
if (eventColors.length >= 2) base.colors = eventColors;
|
||||
base.layout = s.layout ?? event.tags.find(([n]) => n === 'layout')?.[1];
|
||||
if (!base.emoji) {
|
||||
const raw = event.content?.trim();
|
||||
@@ -150,7 +145,6 @@ export function resolveStationery(s: Stationery): ResolvedStationery {
|
||||
// No event or unknown kind — use legacy flat fallbacks (old letters, presets)
|
||||
base.textColor = s.textColor;
|
||||
base.primaryColor = s.primaryColor;
|
||||
base.colors = s.colors;
|
||||
base.layout = s.layout;
|
||||
base.imageUrl = s.imageUrl;
|
||||
base.imageMode = s.imageMode ?? 'cover';
|
||||
|
||||
Reference in New Issue
Block a user