[feat] solution/site,docs: 엽서 쓰기를 발행본에 넣는다 — 그리기 규칙은 한 파일로
시연본에만 주입 스크립트로 있던 엽서 쓰기를 발행본 컴포넌트로 옮겼다(2026-09-14 대표:
"웹빌드 해도 엽서쓰기 나오게"). 사진이 있는 사이트면 섹션이 나간다.
★ 캔버스가 남의 도메인 사진에 오염되면 저장·공유가 SecurityError 로 죽는다 — 미리보기는
보이는데 내보내기만 막히는, 눈으로는 못 찾는 종류다. 실측: 발행본 사진은 네이버 CDN 에 있고
그쪽은 Access-Control-Allow-Origin 을 주지 않는다(curl -I 확인). CORS 로 먼저 받아 보고
실패하면 CORS 없이 받아 **미리보기만** 세우고 저장·공유 단추를 감춘다.
- lib/postcard-canvas.ts(신규): 그리기만 한다. 여백 56 · 우표 칸 190 · 최대 4줄은 시연본과 같은 값
- sections/items/PostcardMakerSection.tsx(신규): 사진 고르기·입력(4줄 제한)·공유/저장
- HomePage: 섹션 설정에 자리가 없는 기능이라 오시는 길과 같은 방식으로 직접 낸다
- package-lock.json: playwright 선언에 맞춰 잠금 갱신(어제 커밋에서 lock 을 안 맞춰 npm ci 가 죽었다)
- DEVLOG: 근본 해결(사진을 우리 오리진으로 옮기기)은 아직 안 했다고 적어 둔다
검증: tsc 통과 · 발행본(/s/statata)에서 사진·문구·우표·소인·서명 줄이 그려지고,
외부 사진이라 저장·공유 대신 안내문이 뜬다(스크린샷 확인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3b4c5c98bc
commit
93bd5435ac
@ -5,6 +5,25 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다)
|
||||
|
||||
**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를
|
||||
발행본 컴포넌트로 옮겼다. 그리기 규칙은 `site/src/lib/postcard-canvas.ts` 한 곳에 두고,
|
||||
화면·입력·공유는 `sections/items/PostcardMakerSection.tsx` 가 맡는다. 사진이 있는 사이트면 나간다.
|
||||
|
||||
**★ 저장·공유가 사진 출처에 걸린다** — 캔버스는 **남의 도메인 사진을 그리면 오염돼서**(tainted)
|
||||
`toBlob` 이 SecurityError 로 막힌다. 미리보기는 멀쩡히 보이는데 저장·공유만 죽는, 눈으로는 못 찾는 종류다.
|
||||
CORS 로 받으면 안 오염되지만 실측(2026-09-14) 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고
|
||||
그쪽은 `Access-Control-Allow-Origin` 을 주지 않는다 — `curl -I` 로 확인했다.
|
||||
|
||||
→ 지금은 **정직하게 막는다.** CORS 로 한 번 받아 보고, 실패하면 CORS 없이 다시 받아 미리보기만 세우고
|
||||
저장·공유 단추를 아예 감춘다("이 사진은 다른 사이트에 올라와 있어 …"). 눌러도 안 되는 단추를 두지 않는다.
|
||||
→ **근본 해결은 사진을 우리 오리진으로 옮기는 것이다.** 시연본이 `img/mirror/` 로 그렇게 하고 있고,
|
||||
발행 파이프라인이 같은 일을 하면(빌드 때 내려받아 `out/s/<slug>/img/` 에 두고 payload 주소를 바꾼다)
|
||||
저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. **아직 안 했다.**
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내)
|
||||
|
||||
**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@ -13,6 +13,9 @@
|
||||
"solution/site",
|
||||
"admin/frontend"
|
||||
],
|
||||
"devDependencies": {
|
||||
"playwright": "^1.63.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
@ -7337,6 +7340,35 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pony-cause": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz",
|
||||
|
||||
236
solution/site/src/lib/postcard-canvas.ts
Normal file
236
solution/site/src/lib/postcard-canvas.ts
Normal file
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 엽서 한 장을 캔버스에 그린다.
|
||||
*
|
||||
* ★ 시연본이 먼저다. `solution/site/scripts/mockup/inject.js` 의 `postcardMaker` 가 이 그림을
|
||||
* 먼저 만들었고(2026-09-14 대표 확정), 여기는 그 규칙을 발행본으로 옮긴 것이다.
|
||||
* 여백 56 · 우표 칸 190 · 최대 4줄 같은 값은 두 곳에 같이 있다 — 하나를 고치면 같이 고친다.
|
||||
* ★ 그리기만 한다. 사진 고르기·글자 입력·공유는 부르는 쪽(PostcardMakerSection)이 맡는다 —
|
||||
* 캔버스 규칙과 화면 조작을 한 파일에 섞으면 둘 다 못 읽게 된다.
|
||||
*/
|
||||
|
||||
/** 정사각형 1080 — 공유 시트에 올라가는 이미지의 1:1 관례와 같다. */
|
||||
export const POSTCARD_SIZE = 1080;
|
||||
/** 문구는 넉 줄까지만 (2026-09-14 대표: "4줄이상 못쓰게 막으라고"). */
|
||||
export const MAX_LINES = 4;
|
||||
|
||||
const MARGIN = 56;
|
||||
const STAMP_COL_W = 190;
|
||||
const INK = '#1b1a15';
|
||||
const PAPER = '#efe7d3';
|
||||
const ACCENT = '#bf2f1b';
|
||||
|
||||
/** 문구가 들어갈 칸의 폭. 입력을 막을 때도 이 값으로 미리 재야 화면과 그림이 갈리지 않는다. */
|
||||
export function textColumnWidth(size: number = POSTCARD_SIZE): number {
|
||||
return size - MARGIN - STAMP_COL_W - MARGIN - 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캔버스는 CSS 처럼 저절로 줄을 안 바꾼다 — 폭에 맞춰 손으로 접는다.
|
||||
* ★ 엔터는 그대로 줄바꿈이다. 먼저 `\n` 으로 문단을 가르고 문단만 접는다 —
|
||||
* 공백과 같이 묶어 쪼개면 손님이 누른 엔터가 사라진다.
|
||||
* ★ 공백 없는 긴 글자열(자음만 이어친 것)은 글자 단위로도 접는다. 안 그러면 우표 칸으로 넘친다.
|
||||
*/
|
||||
export function wrapLines(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
||||
const lines: string[] = [];
|
||||
text.split('\n').forEach((para) => {
|
||||
if (para.trim() === '') {
|
||||
lines.push('');
|
||||
return;
|
||||
}
|
||||
let line = '';
|
||||
para.split(' ').forEach((word) => {
|
||||
const test = line ? `${line} ${word}` : word;
|
||||
if (ctx.measureText(test).width <= maxWidth) {
|
||||
line = test;
|
||||
return;
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
if (ctx.measureText(word).width <= maxWidth) {
|
||||
line = word;
|
||||
return;
|
||||
}
|
||||
let chunk = '';
|
||||
for (const ch of word) {
|
||||
const next = chunk + ch;
|
||||
if (ctx.measureText(next).width > maxWidth && chunk) {
|
||||
lines.push(chunk);
|
||||
chunk = ch;
|
||||
} else {
|
||||
chunk = next;
|
||||
}
|
||||
}
|
||||
line = chunk;
|
||||
});
|
||||
if (line) lines.push(line);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소인(우표 옆 회전 원) 안 상호명.
|
||||
* ★ 업소 이름을 박아 두지 않는다 — 숙소든 카페든 같은 코드가 그린다. 콤마·공백에서 먼저 끊고,
|
||||
* 그래도 넓으면 글자 단위로 접는다("스테이,머뭄" 처럼 공백 없는 상호가 있다).
|
||||
*/
|
||||
function stampLines(ctx: CanvasRenderingContext2D, name: string, maxWidth: number): string[] {
|
||||
const lines: string[] = [];
|
||||
let line = '';
|
||||
name.split(/[,\s]+/).filter(Boolean).forEach((part) => {
|
||||
const test = line ? `${line} ${part}` : part;
|
||||
if (ctx.measureText(test).width <= maxWidth) {
|
||||
line = test;
|
||||
return;
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
if (ctx.measureText(part).width <= maxWidth) {
|
||||
line = part;
|
||||
return;
|
||||
}
|
||||
let chunk = '';
|
||||
for (const ch of part) {
|
||||
const next = chunk + ch;
|
||||
if (ctx.measureText(next).width > maxWidth && chunk) {
|
||||
lines.push(chunk);
|
||||
chunk = ch;
|
||||
} else {
|
||||
chunk = next;
|
||||
}
|
||||
}
|
||||
line = chunk;
|
||||
});
|
||||
if (line) lines.push(line);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사진 한 장. 돌려주는 `exportable` 이 **이 엽서를 파일로 뽑을 수 있는지**를 가른다.
|
||||
*
|
||||
* ★ 캔버스는 남의 도메인 사진을 그리면 오염돼서(tainted) `toBlob` 이 막힌다 — 미리보기는
|
||||
* 보이는데 저장·공유만 SecurityError 로 죽는, 눈으로는 못 찾는 종류다.
|
||||
* ★ CORS 로 받아오면 안 오염된다. 그래서 먼저 `crossOrigin="anonymous"` 로 받아 본다.
|
||||
* 실측(2026-09-14): 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고 그쪽은
|
||||
* `Access-Control-Allow-Origin` 을 주지 않는다 — 그래서 이 시도는 실패하고,
|
||||
* **CORS 없이 다시 받아 미리보기만** 세운다(저장·공유는 부르는 쪽이 막는다).
|
||||
* ★ 근본 해결은 사진을 우리 오리진으로 옮기는 것이다(시연본이 `img/mirror/` 로 그렇게 한다).
|
||||
*/
|
||||
async function loadImage(url: string): Promise<{img: HTMLImageElement; exportable: boolean}> {
|
||||
const tryLoad = (crossOrigin: string | null) => new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
if (crossOrigin) img.crossOrigin = crossOrigin;
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
try {
|
||||
return {img: await tryLoad('anonymous'), exportable: true};
|
||||
} catch {
|
||||
return {img: await tryLoad(null), exportable: false};
|
||||
}
|
||||
}
|
||||
|
||||
/** 이 사이트가 쓰는 세리프를 그대로 쓴다 — 화면의 카드와 그림의 글씨가 갈리지 않게. */
|
||||
function serifFamily(): string {
|
||||
const probe = document.querySelector('.serif');
|
||||
const family = probe ? getComputedStyle(probe).fontFamily : '';
|
||||
return family || 'serif';
|
||||
}
|
||||
|
||||
/** 돌려주는 값: 이 그림을 파일로 뽑을 수 있는가(사진이 오염시키지 않았는가). */
|
||||
export async function drawPostcard(
|
||||
canvas: HTMLCanvasElement,
|
||||
{photoUrl, text, placeName, region}: {photoUrl: string; text: string; placeName: string; region: string},
|
||||
): Promise<boolean> {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return false;
|
||||
const size = canvas.width;
|
||||
const serif = serifFamily();
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
const {img, exportable} = await loadImage(photoUrl);
|
||||
|
||||
// 위 — 사진(실제 카드 앞면과 같은 3:2)
|
||||
const photoW = size - MARGIN * 2;
|
||||
const photoH = photoW / 1.5;
|
||||
const scale = Math.max(photoW / img.width, photoH / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(MARGIN, MARGIN, photoW, photoH);
|
||||
ctx.clip();
|
||||
ctx.drawImage(img, MARGIN + (photoW - w) / 2, MARGIN + (photoH - h) / 2, w, h);
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = 'rgba(27,26,21,0.25)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(MARGIN + 0.5, MARGIN + 0.5, photoW - 1, photoH - 1);
|
||||
|
||||
// 아래 — 괘선으로 가른 두 칸(문구 | 우표)
|
||||
const bodyY = MARGIN + photoH + 36;
|
||||
const dividerX = size - MARGIN - STAMP_COL_W;
|
||||
const textColW = textColumnWidth(size);
|
||||
ctx.strokeStyle = 'rgba(27,26,21,0.35)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dividerX, bodyY);
|
||||
ctx.lineTo(dividerX, size - MARGIN - 46);
|
||||
ctx.stroke();
|
||||
|
||||
// 왼쪽 — 손님이 쓴 한 마디
|
||||
ctx.fillStyle = INK;
|
||||
ctx.font = `600 46px ${serif}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
const wrapped = wrapLines(ctx, `“${text.trim() || '오늘, 이 숙소에서 하루를 보냅니다.'}”`, textColW);
|
||||
// 입력에서 이미 막지만 여기서도 자른다 — 붙여넣기 같은 다른 경로로 들어와도 카드가 안 깨지게.
|
||||
const lines = wrapped.slice(0, MAX_LINES);
|
||||
if (wrapped.length > MAX_LINES) {
|
||||
let last = lines[MAX_LINES - 1];
|
||||
while (last.length && ctx.measureText(`${last}…`).width > textColW) last = last.slice(0, -1);
|
||||
lines[MAX_LINES - 1] = `${last}…`;
|
||||
}
|
||||
lines.forEach((line, i) => ctx.fillText(line, MARGIN, bodyY + 50 + i * 60));
|
||||
|
||||
// 오른쪽 — 우표 칸과 소인
|
||||
const stampCx = dividerX + STAMP_COL_W / 2;
|
||||
ctx.strokeStyle = INK;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(stampCx - 44, bodyY, 88, 108);
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = INK;
|
||||
ctx.font = `400 20px ${serif}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('郵票', stampCx, bodyY + 46);
|
||||
ctx.fillText('10원', stampCx, bodyY + 78);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(stampCx, bodyY + 210);
|
||||
ctx.rotate((-6 * Math.PI) / 180);
|
||||
ctx.strokeStyle = ACCENT;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 62, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = ACCENT;
|
||||
ctx.font = `600 22px ${serif}`;
|
||||
const nameLines = stampLines(ctx, placeName, 100).slice(0, 2);
|
||||
const startY = 8 - ((nameLines.length - 1) * 26) / 2;
|
||||
nameLines.forEach((line, i) => ctx.fillText(line, 0, startY + i * 26));
|
||||
ctx.restore();
|
||||
|
||||
// 맨 아래 — 점선과 서명 줄
|
||||
const footY = size - MARGIN - 30;
|
||||
ctx.strokeStyle = 'rgba(27,26,21,0.3)';
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN, footY - 20);
|
||||
ctx.lineTo(size - MARGIN, footY - 20);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = 'rgba(27,26,21,0.6)';
|
||||
ctx.font = `400 22px ${serif}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(`${placeName} · ${region}`, MARGIN, footY + 6);
|
||||
return exportable;
|
||||
}
|
||||
@ -14,6 +14,7 @@ import {
|
||||
LocalGuideSection,
|
||||
WeatherSection,
|
||||
LocationSection,
|
||||
PostcardMakerSection,
|
||||
SpaceSection,
|
||||
UnitsSection,
|
||||
StayBookingSection,
|
||||
@ -111,6 +112,11 @@ export function HomePage() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 엽서 쓰기 — 사진이 있으면 낸다 (2026-09-14 대표: "웹빌드 해도 엽서쓰기 나오게").
|
||||
섹션 설정에 자리가 없는 기능이라 오시는 길과 같은 방식으로 여기서 직접 낸다.
|
||||
사진이 한 장도 없으면 만들 엽서가 없으므로 컴포넌트가 스스로 빠진다. */}
|
||||
<PostcardMakerSection />
|
||||
|
||||
{/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */}
|
||||
{!isSectionEnabled(payload, 'map') && <LocationSection />}
|
||||
|
||||
|
||||
@ -21,3 +21,4 @@ export {SiteFooter} from './SiteFooter';
|
||||
export {MobileTabBar} from './MobileTabBar';
|
||||
// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(theme.sections[].data).
|
||||
export {ITEM_SECTIONS} from './items';
|
||||
export {PostcardMakerSection} from './items/PostcardMakerSection';
|
||||
|
||||
173
solution/site/src/sections/items/PostcardMakerSection.tsx
Normal file
173
solution/site/src/sections/items/PostcardMakerSection.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 엽서 쓰기 — 손님이 사진을 고르고 한 마디를 적어 그 자리에서 엽서를 만들어 공유한다.
|
||||
*
|
||||
* ★ 시연본(`scripts/mockup/inject.js` postcardMaker)에서 옮겨 왔다 (2026-09-14 대표:
|
||||
* "웹빌드 해도 엽서쓰기 나오게"). 시연본은 주입 스크립트라 발행본에는 없었다.
|
||||
* ★ 그림 규칙은 `@site/lib/postcard-canvas` 한 곳에 있다 — 여기서는 고르기·입력·공유만 한다.
|
||||
* ★ **파일 그대로 공유**다(대표 확정). Web Share API 가 파일을 받으면 이미지가 그대로 대화방에
|
||||
* 올라간다. 못 받는 브라우저(주로 데스크톱)는 링크만 보내고, 그것도 안 되면 저장으로 떨어진다.
|
||||
* ★ 버튼 문구에 앱 이름을 못 박지 않는다 — 공유 시트는 OS 가 채운다. 손끝으로 쓰는 기기에서만
|
||||
* "카카오톡 등으로 공유" 라고 적는다(맥에서는 메시지·AirDrop 정도만 떠서 거짓말이 된다).
|
||||
*/
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {Section} from '@site/lib/ui';
|
||||
import {MAX_LINES, POSTCARD_SIZE, drawPostcard, textColumnWidth, wrapLines} from '@site/lib/postcard-canvas';
|
||||
|
||||
const FILE_NAME = 'postcard.png';
|
||||
|
||||
export function PostcardMakerSection() {
|
||||
const payload = useSite();
|
||||
const photos = (payload.media ?? []).filter((m) => m.url);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [picked, setPicked] = useState(0);
|
||||
const [text, setText] = useState('');
|
||||
const [hint, setHint] = useState('');
|
||||
const [touch, setTouch] = useState(false);
|
||||
/* 사진이 남의 도메인이면 캔버스가 오염돼 파일로 못 뽑는다(postcard-canvas.loadImage 주석).
|
||||
그때는 미리보기만 남기고 저장·공유를 막는다 — 눌러도 안 되는 단추를 두지 않는다. */
|
||||
const [exportable, setExportable] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setTouch(window.matchMedia?.('(pointer: coarse)').matches ?? false);
|
||||
}, []);
|
||||
|
||||
const placeName = payload.place.name;
|
||||
const region = String(payload.place.address ?? '').split(' ').slice(0, 2).join(' ');
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const photo = photos[picked];
|
||||
if (!canvas || !photo?.url) return;
|
||||
// 글자를 칠 때마다 다시 그린다. 80ms 는 시연본과 같은 값 — 한 글자마다 그리면 캔버스가 버벅인다.
|
||||
const timer = window.setTimeout(() => {
|
||||
void drawPostcard(canvas, {photoUrl: photo.url, text, placeName, region}).then(setExportable);
|
||||
}, 80);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [picked, text, placeName, region, photos]);
|
||||
|
||||
// 폰트가 늦게 실리면 글씨가 기본 서체로 그려진 채 남는다 — 실리고 나서 한 번 더 그린다.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const photo = photos[picked];
|
||||
if (!canvas || !photo?.url || !document.fonts?.ready) return;
|
||||
void document.fonts.ready.then(() => drawPostcard(canvas, {photoUrl: photo.url, text, placeName, region}).then(setExportable));
|
||||
// 첫 그림 한 번만 — 이후는 위 효과가 맡는다.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (photos.length === 0) return null;
|
||||
|
||||
/** 몇 줄로 접힐지는 폭·서체에 달려 글자 수로는 못 잰다 — 그릴 때와 같은 함수로 재서 막는다. */
|
||||
function withinLineLimit(next: string): boolean {
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx) return true;
|
||||
ctx.font = `600 46px serif`;
|
||||
return wrapLines(ctx, `“${next}”`, textColumnWidth()).length <= MAX_LINES;
|
||||
}
|
||||
|
||||
function exportBlob(): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return resolve(null);
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/png', 0.95);
|
||||
});
|
||||
}
|
||||
|
||||
function download(blob: Blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = FILE_NAME;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function share() {
|
||||
const blob = await exportBlob();
|
||||
if (!blob) return;
|
||||
const pageUrl = document.querySelector<HTMLMetaElement>('meta[property="og:url"]')?.content
|
||||
?? `${location.origin}${location.pathname}`;
|
||||
const file = new File([blob], FILE_NAME, {type: 'image/png'});
|
||||
// 손님이 시트를 닫은 것도 거절로 온다 — 오류로 다루지 않는다.
|
||||
if (navigator.canShare?.({files: [file]})) {
|
||||
await navigator.share({files: [file], title: `${placeName} 엽서`, text: pageUrl}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (navigator.share) {
|
||||
await navigator.share({title: `${placeName} 엽서`, url: pageUrl}).catch(() => {});
|
||||
setHint('이 브라우저는 사진을 같이 못 보내 링크만 보냈습니다. 사진은 "이미지로 저장" 을 눌러 주세요.');
|
||||
return;
|
||||
}
|
||||
download(blob);
|
||||
setHint('이 브라우저는 공유 시트를 못 열어 저장으로 대신했습니다.');
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="postcard-maker"
|
||||
title="엽서 쓰기"
|
||||
lead={`${placeName}에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.`}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-[minmax(0,1fr)_minmax(0,320px)]">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={POSTCARD_SIZE}
|
||||
height={POSTCARD_SIZE}
|
||||
aria-label="내가 만든 엽서 미리보기"
|
||||
className="border-line w-full border"
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="slider-viewport" role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`} tabIndex={0}>
|
||||
<div className="slider-track" style={{gap: '0.5rem'}}>
|
||||
{photos.map((photo, i) => (
|
||||
<button
|
||||
key={photo.url}
|
||||
type="button"
|
||||
onClick={() => setPicked(i)}
|
||||
aria-pressed={i === picked}
|
||||
aria-label={`사진 ${i + 1}`}
|
||||
className="border-line size-16 shrink-0 overflow-hidden rounded border transition-opacity"
|
||||
style={{opacity: i === picked ? 1 : 0.5}}
|
||||
>
|
||||
<img src={photo.url} alt="" loading="lazy" className="size-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={3}
|
||||
maxLength={60}
|
||||
value={text}
|
||||
placeholder="엽서에 쓸 한 마디를 적어 보세요"
|
||||
onChange={(e) => { if (withinLineLimit(e.target.value)) setText(e.target.value); }}
|
||||
className="border-line w-full rounded border p-3 text-[length:var(--fs-sm)]"
|
||||
/>
|
||||
<p className="text-muted text-[length:var(--fs-xs)]">{text.length}/60</p>
|
||||
|
||||
{exportable ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => void share()} className="tap border-line rounded-full border px-4 py-2 text-[length:var(--fs-sm)] font-bold">
|
||||
{touch ? '카카오톡 등으로 공유' : '공유하기'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void exportBlob().then((b) => b && download(b))}
|
||||
className="tap border-line rounded-full border px-4 py-2 text-[length:var(--fs-sm)]"
|
||||
>
|
||||
이미지로 저장
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* 브라우저 정책이라 여기서는 못 넘는다 — 사진이 이 사이트 주소로 올라와야 풀린다. */
|
||||
<p className="text-muted text-[length:var(--fs-sm)]">
|
||||
이 사진은 다른 사이트에 올라와 있어 저장·공유가 막혀 있습니다. 화면으로만 보여 드립니다.
|
||||
</p>
|
||||
)}
|
||||
{hint && <p className="text-muted text-[length:var(--fs-xs)]">{hint}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user