diff --git a/libs/leon-sans-react/src/domain/WreathSansController.ts b/libs/leon-sans-react/src/domain/WreathSansController.ts index ae5223e8..32c602a5 100644 --- a/libs/leon-sans-react/src/domain/WreathSansController.ts +++ b/libs/leon-sans-react/src/domain/WreathSansController.ts @@ -1,5 +1,5 @@ import gsap, { Power0, Power3 } from 'gsap'; -import { Align, CHARSET, ModelData } from 'leonsans'; +import { Align, CHARSET, ModelData, Rect } from 'leonsans'; import LeonSans from 'leonsans'; import * as PIXI from 'pixi.js'; @@ -42,6 +42,7 @@ type LeonOptions = { size?: number; weight?: number; pathGap?: number; + align?: Align; }; type WreathSansProps = { @@ -99,14 +100,16 @@ export default class WreathSansController { }: WreathSansProps) { const canvasWidth = canvas?.clientWidth ?? 300; const canvasHeight = canvas?.clientHeight ?? 300; + const initialSize = dynamicSize ? (leonOptions?.size ?? 130) : 0; this.leon = new LeonSans({ text: initialText, color: [leonOptions?.color ?? '#704234'], - size: 0, + size: initialSize, weight: leonOptions?.weight ?? 400, isPattern: true, pathGap: leonOptions?.pathGap ?? 1 / 20, + align: leonOptions?.align ?? 'left', }); this.leon.update(); @@ -178,6 +181,7 @@ export default class WreathSansController { set align(align: Align) { this.leon.align = align; + this.leon.updateDrawingPaths(); this.updatePositions(); } @@ -340,6 +344,15 @@ export default class WreathSansController { }); } + getTextRect(): Rect { + return { + x: this.leon.rect.x, + y: this.leon.rect.y, + w: this.leon.rect.w, + h: this.leon.rect.h, + }; + } + /** * leonsans의 데이터를 사용하여 글자들의 위치를 업데이트한다. * 그러나 leonsans에는 영향을 주지 않는다. @@ -373,7 +386,7 @@ export default class WreathSansController { private dynamicResize() { if (this.leon.rect.w === 0) return; const newSize = this.size * (this.canvas.clientWidth / this.leon.rect.w); - if (newSize >= this._minSize) this.size = newSize; + this.size = Math.max(newSize, this._minSize); } private async loadAssets() { diff --git a/libs/leon-sans-react/src/hooks/createWreathSans.tsx b/libs/leon-sans-react/src/hooks/createWreathSans.tsx index 29d7aae7..50bac20f 100644 --- a/libs/leon-sans-react/src/hooks/createWreathSans.tsx +++ b/libs/leon-sans-react/src/hooks/createWreathSans.tsx @@ -1,4 +1,4 @@ -import { CHARSET } from 'leonsans'; +import { Align, CHARSET } from 'leonsans'; import { ComponentPropsWithoutRef } from 'react'; import WreathSansController from '../domain/WreathSansController'; @@ -9,6 +9,7 @@ type Props = { color?: string; size?: number; weight?: number; + align?: Align; // canvas config width?: number; height?: number; @@ -16,6 +17,8 @@ type Props = { background?: string; backgroundAlpha?: number; darkMode?: boolean; + fitToWidth?: boolean; + minSize?: number; }; export default function createWreathSans({ @@ -23,21 +26,27 @@ export default function createWreathSans({ color = '#000000', size = 60, weight = 400, + align = 'left', width = 800, height = 600, background = '#ffffff', backgroundAlpha = 1, darkMode, + fitToWidth = false, + minSize, }: Props) { const wreathSansController = new WreathSansController({ initialText, background, backgroundAlpha, darkMode, + dynamicSize: fitToWidth, + minSize, leonOptions: { color, size, weight, + align, }, }); @@ -134,11 +143,21 @@ export default function createWreathSans({ return wreathSansController.leon.text; } + function setAlign(newAlign: Align) { + wreathSansController.align = newAlign; + } + + function getTextRect() { + return wreathSansController.getTextRect(); + } + return { WreathSansCanvas, onInputHandler, resize, redraw, getText, + setAlign, + getTextRect, }; } diff --git a/projects/waffle-sans/src/components/Letter.tsx b/projects/waffle-sans/src/components/Letter.tsx index 297354de..b4019781 100644 --- a/projects/waffle-sans/src/components/Letter.tsx +++ b/projects/waffle-sans/src/components/Letter.tsx @@ -12,14 +12,27 @@ type LetterProps = { content: string; sans: string; mode: string; + align: string; }; -export default function Letter({ sender, content, sans, mode }: LetterProps) { +export default function Letter({ + sender, + content, + sans, + mode, + align, +}: LetterProps) { const [stage, setStage] = useState<(typeof stages)[number]>('shake'); + const [contentHeight, setContentHeight] = useState(null); const parsedMode = useMemo( () => (mode === 'o' ? 'outside' : 'inside'), [mode], ); + const baseHeight = stage === 'out' ? 652 : 252; + const adjustedHeight = + stage === 'out' && contentHeight + ? Math.max(baseHeight, contentHeight) + : baseHeight; const onClickLetter = useCallback(() => { const currentStageIndex = stages.findIndex((s) => s === stage); @@ -33,7 +46,7 @@ export default function Letter({ sender, content, sans, mode }: LetterProps) { $isOutside={parsedMode === 'outside'} $isOut={stage === 'out'} /> - + ` +const Container = styled.div<{ + $stage: 'shake' | 'close' | 'open' | 'out'; + $height: number; +}>` position: relative; width: 100%; - height: ${({ $stage }) => ($stage === 'out' ? '652px' : '252px')}; + height: ${({ $height }) => `${$height}px`}; transition: 1s ease; diff --git a/projects/waffle-sans/src/components/PostPreview.tsx b/projects/waffle-sans/src/components/PostPreview.tsx index e52124c3..badc5e1f 100644 --- a/projects/waffle-sans/src/components/PostPreview.tsx +++ b/projects/waffle-sans/src/components/PostPreview.tsx @@ -15,10 +15,15 @@ export default function PostPreview({ mode = Mode.OUTSIDE }: Props) { const value = useRecoilValue(postFormState); const url = useMemo(() => new URL(window.location.href), []); const sans = useMemo(() => decoder(url, 'sans'), [url]); + const align = useMemo(() => decoder(url, 'align'), [url]); const { ref, WreathSansCanvas } = useWreathSans({ initialText: sans, darkMode: mode === Mode.OUTSIDE, fontColor: mode === Mode.OUTSIDE ? '#704234' : '#B27E41', + align: + align === 'left' || align === 'center' || align === 'right' + ? align + : undefined, }); return ( diff --git a/projects/waffle-sans/src/components/ReceivedContent.tsx b/projects/waffle-sans/src/components/ReceivedContent.tsx index 5fec2481..a2f475a1 100644 --- a/projects/waffle-sans/src/components/ReceivedContent.tsx +++ b/projects/waffle-sans/src/components/ReceivedContent.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; import useWreathSans from '../hooks/useWreathSans'; @@ -8,7 +8,9 @@ type ReceivedContentProps = { content: string; sans: string; mode: string; - stage?: string; + stage: string; + align: string; + onHeightChange: (height: number) => void; }; export default function ReceivedContent({ @@ -17,33 +19,98 @@ export default function ReceivedContent({ sans, mode, stage, + align, + onHeightChange, }: ReceivedContentProps) { const [width, setWidth] = useState(280); const [height, setHeight] = useState(196); + const [baseHeight, setBaseHeight] = useState(null); + // const [containerHeight, setContainerHeight] = useState(null); + const [sansHeight, setSansHeight] = useState(null); + const containerRef = useRef(null); + const baseSansHeight = useMemo( + () => (baseHeight ? baseHeight * 0.3 : null), + [baseHeight], + ); - const { ref, WreathSansCanvas, redraw } = useWreathSans({ + const { ref, WreathSansCanvas, redraw, resize, getTextRect } = useWreathSans({ width, height, initialText: sans, darkMode: mode === 'o', fontColor: mode === 'o' ? '#704234' : '#B27E41', + fitToWidth: true, + minSize: 0, + align: + align === 'left' || align === 'center' || align === 'right' + ? align + : undefined, }); useEffect(() => { - if (ref?.current) { - setWidth(ref?.current?.offsetWidth); - setHeight(ref.current.offsetHeight); - redraw(); - } - }, [redraw, ref]); + const updateBaseSize = () => { + if (!containerRef.current) return; + const containerWidth = containerRef.current.offsetWidth; + setBaseHeight(containerWidth / 0.55); + if (ref.current) { + const wrapperWidth = ref.current.offsetWidth; + setWidth(wrapperWidth); + } + }; + updateBaseSize(); + window.addEventListener('resize', updateBaseSize); + return () => { + window.removeEventListener('resize', updateBaseSize); + }; + }, []); + + useEffect(() => { + if (!baseHeight) return; + const updateSansHeight = () => { + const rect = getTextRect(); + if (!rect || rect.h === 0) return; + const targetHeight = Math.ceil(rect.h + 12); + const minHeight = baseSansHeight ?? 0; + setSansHeight(Math.max(targetHeight, minHeight)); + }; + const rafId = requestAnimationFrame(updateSansHeight); + return () => cancelAnimationFrame(rafId); + }, [baseHeight, baseSansHeight, getTextRect, sans, stage]); + + // TODO: 필요한가? + // useEffect(() => { + // if (!baseHeight || !baseSansHeight) return; + // const nextSansHeight = sansHeight ?? baseSansHeight; + // const delta = Math.max(0, nextSansHeight - baseSansHeight); + // const nextHeight = Math.ceil(baseHeight + delta); + // setContainerHeight(nextHeight); + // onHeightChange?.(nextHeight); + // }, [baseHeight, baseSansHeight, onHeightChange, sansHeight]); + + useEffect(() => { + if (!containerRef.current) return; + onHeightChange(containerRef.current.offsetHeight); + }, [sansHeight, containerRef, onHeightChange]); + + + useEffect(() => { + if (!sansHeight || !width) return; + setHeight(sansHeight); + resize(width, sansHeight); + redraw(); + }, [redraw, resize, sansHeight, width]); useEffect(() => { redraw(); }, [redraw, stage]); return ( - - + + {content} @@ -53,40 +120,42 @@ export default function ReceivedContent({ ); } -const Container = styled.div<{ $isOutside: boolean }>` +const OUTSIDE_LETTER_BG = `${import.meta.env.BASE_URL}background_outside_letter.png`; +const INSIDE_LETTER_BG = `${import.meta.env.BASE_URL}background_inside_letter.png`; + +const Container = styled.div<{ $isOutside: boolean; $height?: number | null }>` position: relative; top: 10px; width: 100%; - aspect-ratio: 0.55; + /* TODO: auto로 두면 안 되는 이유? */ + /* height: ${({ $height }) => ($height ? `${$height}px` : 'auto')}; + aspect-ratio: ${({ $height }) => ($height ? 'auto' : '0.55')}; */ box-sizing: border-box; - padding: 45px 40px; + padding: 45px 40px 80px 40px; display: flex; flex-direction: column; align-items: center; - gap: 3%; + gap: 10px; - background-image: url(${({ $isOutside }) => - $isOutside - ? `${import.meta.env.BASE_URL}background_outside_letter.png` - : `${import.meta.env.BASE_URL}background_inside_letter.png`}); + background-image: url(${({ $isOutside }) => $isOutside ? OUTSIDE_LETTER_BG : INSIDE_LETTER_BG}); background-size: cover; background-position: bottom center; box-shadow: 0 6px 6px 0 rgba(0, 0, 0, 0.15); `; -const SansWrapper = styled.div` +const SansWrapper = styled.div<{ $height: number | null }>` width: 100%; - height: 30%; + height: ${({ $height }) => ($height ? `${$height}px` : '30%')}; `; -const MainText = styled.div<{ $isOutside: boolean }>` +const MainText = styled.pre<{ $isOutside: boolean }>` color: ${({ $isOutside }) => ($isOutside ? `#315c57` : `#FEDCB4`)}; width: 100%; - height: 30%; flex-shrink: 0; overflow-y: auto; + white-space: pre-wrap; text-align: justify; font-family: Inter; diff --git a/projects/waffle-sans/src/components/SansForm.tsx b/projects/waffle-sans/src/components/SansForm.tsx index c469fe0c..615e1a7f 100644 --- a/projects/waffle-sans/src/components/SansForm.tsx +++ b/projects/waffle-sans/src/components/SansForm.tsx @@ -14,19 +14,27 @@ import Textarea from './Textarea'; interface Props { mode?: Mode; + align?: 'left' | 'center' | 'right'; } -export default function SansForm({ mode = Mode.OUTSIDE }: Props) { +export default function SansForm({ mode = Mode.OUTSIDE, align }: Props) { const router = useNavigate(); const defaultValue = useMemo(() => 'interactive study', []); - const { ref, WreathSansCanvas, redraw, resize, getText, onInputHandler } = - useWreathSans({ - width: window.innerWidth, - height: (window.innerHeight / 100) * 62, - initialText: defaultValue, - darkMode: mode === Mode.OUTSIDE, - fontColor: mode === Mode.OUTSIDE ? '#704234' : '#B27E41', - }); + const { + ref, + WreathSansCanvas, + redraw, + resize, + getText, + onInputHandler, + setAlign, + } = useWreathSans({ + width: window.innerWidth, + height: (window.innerHeight / 100) * 62, + initialText: defaultValue, + darkMode: mode === Mode.OUTSIDE, + fontColor: mode === Mode.OUTSIDE ? '#704234' : '#B27E41', + }); const handleShare = useCallback(() => { if (!getText().trim()) { @@ -34,10 +42,15 @@ export default function SansForm({ mode = Mode.OUTSIDE }: Props) { return; } const text = encoder(getText()); + const encodedAlign = encoder(align ?? 'left'); mode === Mode.OUTSIDE - ? router(`/o-post?sans=${text}&mode=${encoder('o')}`) - : router(`/i-post?sans=${text}&mode=${encoder('i')}`); - }, [getText, mode, router]); + ? router( + `/o-post?sans=${text}&mode=${encoder('o')}&align=${encodedAlign}`, + ) + : router( + `/i-post?sans=${text}&mode=${encoder('i')}&align=${encodedAlign}`, + ); + }, [align, getText, mode, router]); useEffect(() => { function handleResize() { @@ -49,6 +62,10 @@ export default function SansForm({ mode = Mode.OUTSIDE }: Props) { }; }, [resize]); + useEffect(() => { + if (align) setAlign(align); + }, [align, setAlign]); + return ( diff --git a/projects/waffle-sans/src/hooks/useWreathSans.ts b/projects/waffle-sans/src/hooks/useWreathSans.ts index 62b182a3..d7439242 100644 --- a/projects/waffle-sans/src/hooks/useWreathSans.ts +++ b/projects/waffle-sans/src/hooks/useWreathSans.ts @@ -8,6 +8,9 @@ interface Params { fontColor?: string; darkMode?: boolean; initialText: string; + fitToWidth?: boolean; + minSize?: number; + align?: 'left' | 'center' | 'right'; } export default function useWreathSans({ @@ -16,30 +19,46 @@ export default function useWreathSans({ darkMode, fontColor, initialText, + fitToWidth, + minSize, + align, }: Params) { const ref = useRef(null); - const { WreathSansCanvas, resize, redraw, getText, onInputHandler } = - useMemo(() => { - return createWreathSans({ - initialText: initialText, - width: width ? width : ref.current?.offsetWidth ?? 330, - height: height ? height : ref.current?.offsetHeight ?? 234, - size: width ? width / 8 : 40, - color: fontColor ?? '#704234', - background: 'transparent', - backgroundAlpha: 0, - darkMode: darkMode ?? false, - }); - }, [ - initialText, - width, - height, - fontColor, - darkMode, - ref.current?.offsetWidth, - ref.current?.offsetHeight, - ]); + const { + WreathSansCanvas, + resize, + redraw, + getText, + onInputHandler, + setAlign, + getTextRect, + } = useMemo(() => { + return createWreathSans({ + initialText: initialText, + width: width ? width : ref.current?.offsetWidth ?? 330, + height: height ? height : ref.current?.offsetHeight ?? 234, + size: width ? width / 8 : 40, + color: fontColor ?? '#704234', + background: 'transparent', + backgroundAlpha: 0, + darkMode: darkMode ?? false, + fitToWidth, + minSize, + align, + }); + }, [ + initialText, + width, + height, + fontColor, + darkMode, + fitToWidth, + minSize, + align, + ref.current?.offsetWidth, + ref.current?.offsetHeight, + ]); useEffect(() => { const handleResize = () => { @@ -54,5 +73,14 @@ export default function useWreathSans({ }; }, [redraw, resize, ref]); - return { WreathSansCanvas, ref, resize, redraw, getText, onInputHandler }; + return { + WreathSansCanvas, + ref, + resize, + redraw, + getText, + onInputHandler, + setAlign, + getTextRect, + }; } diff --git a/projects/waffle-sans/src/pages/InsideSans.tsx b/projects/waffle-sans/src/pages/InsideSans.tsx index c62f112a..28325528 100644 --- a/projects/waffle-sans/src/pages/InsideSans.tsx +++ b/projects/waffle-sans/src/pages/InsideSans.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; @@ -9,8 +10,9 @@ import SnowFlakes from '../components/SnowFlakes'; import { GRID } from '../constants/breakpoint'; import { Mode } from '../types/mode'; -export default function OutsideSans() { +export default function InsideSans() { const router = useNavigate(); + const [align, setAlign] = useState<'left' | 'center' | 'right'>('left'); return ( @@ -19,7 +21,30 @@ export default function OutsideSans() {
- + + setAlign('left')} + > + LEFT + + setAlign('center')} + > + CENTER + + setAlign('right')} + > + RIGHT + + + @@ -95,6 +120,25 @@ const Content = styled.div` } `; +const AlignControls = styled.div` + display: flex; + gap: 8px; + margin-bottom: 8px; +`; + +const AlignButton = styled.button<{ $active: boolean }>` + appearance: none; + border: 1px solid ${({ $active }) => ($active ? '#fff' : '#d8c7b8')}; + background: ${({ $active }) => ($active ? '#ffffff' : 'transparent')}; + color: ${({ $active }) => ($active ? '#5e3517' : '#fff')}; + font-family: inherit; + font-size: 12px; + letter-spacing: 0.08em; + padding: 6px 10px; + border-radius: 999px; + cursor: pointer; +`; + const BackBtnContainer = styled.div` width: auto; height: auto; diff --git a/projects/waffle-sans/src/pages/OutsideSans.tsx b/projects/waffle-sans/src/pages/OutsideSans.tsx index 57dda05d..ebe2d98b 100644 --- a/projects/waffle-sans/src/pages/OutsideSans.tsx +++ b/projects/waffle-sans/src/pages/OutsideSans.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; @@ -9,12 +10,36 @@ import { GRID } from '../constants/breakpoint'; export default function OutsideSans() { const router = useNavigate(); + const [align, setAlign] = useState<'left' | 'center' | 'right'>('left'); return (
- + + setAlign('left')} + > + LEFT + + setAlign('center')} + > + CENTER + + setAlign('right')} + > + RIGHT + + + @@ -62,6 +87,25 @@ const Content = styled.div` } `; +const AlignControls = styled.div` + display: flex; + gap: 8px; + margin-bottom: 8px; +`; + +const AlignButton = styled.button<{ $active: boolean }>` + appearance: none; + border: 1px solid ${({ $active }) => ($active ? '#2e3a2c' : '#c6d7c5')}; + background: ${({ $active }) => ($active ? '#2e3a2c' : 'transparent')}; + color: ${({ $active }) => ($active ? '#ffffff' : '#2e3a2c')}; + font-family: inherit; + font-size: 12px; + letter-spacing: 0.08em; + padding: 6px 10px; + border-radius: 999px; + cursor: pointer; +`; + const BackBtnContainer = styled.div` position: absolute; width: auto; diff --git a/projects/waffle-sans/src/pages/Receive.tsx b/projects/waffle-sans/src/pages/Receive.tsx index 802da7e2..96465303 100644 --- a/projects/waffle-sans/src/pages/Receive.tsx +++ b/projects/waffle-sans/src/pages/Receive.tsx @@ -6,7 +6,7 @@ import MobileFooter from '../components/MobileFooter'; import { decodeParams } from '../utils/crypto'; export default function Receive() { - const { sender, content, receiver, sans, mode } = useMemo( + const { sender, content, receiver, sans, mode, align } = useMemo( () => decodeParams(new URL(window.location.href)), [], ); @@ -16,7 +16,13 @@ export default function Receive() {
{receiver}님에게 편지가 왔어요! - + diff --git a/projects/waffle-sans/src/utils/crypto.ts b/projects/waffle-sans/src/utils/crypto.ts index f9d621ba..09ab4188 100644 --- a/projects/waffle-sans/src/utils/crypto.ts +++ b/projects/waffle-sans/src/utils/crypto.ts @@ -21,6 +21,7 @@ export const decodeParams = (url: URL) => { const decodedReceiver = decoder(url, 'receiver'); const decodedSans = decoder(url, 'sans'); const decodedMode = decoder(url, 'mode'); + const decodedAlign = decoder(url, 'align'); return { sender: decodedSender, @@ -28,5 +29,6 @@ export const decodeParams = (url: URL) => { receiver: decodedReceiver, sans: decodedSans, mode: decodedMode, + align: decodedAlign, }; };