본문으로 건너뛰기
언어

@plott/webkit-browser0.4.0

브라우저·앱 WebView 공통 — 한글 입력(IME) 중 Enter 거르기, 화면 키보드가 가린 영역 CSS 변수, presigned 업로드(진행률·상태 검사·필드 순서), 파일 검사(형식·크기·실제 바이트), 파일 저장, 던지지 않는 저장소, 외부 결제·본인 확인 뒤 돌아올 기록 위치

설치

pnpm
pnpm add @plott/webkit-browser@0.4.0

진입점(exports)

import타입파일
@plott/webkit-browser./dist/index.d.ts./dist/index.js

브라우저와 앱 WebView 에서 쓰는 작은 도구들입니다. 읽는 사람은 입력칸·바텀 시트·파일 업로드·외부 결제 복귀를 만드는 프론트엔드 개발자입니다.

런타임 의존성은 없습니다. checkFile 의 내용 검사(첫 바이트로 형식을 가리는 일)만 선택 peer @plott/webkit-sanitize 의 /files 를 부릅니다. checkFile 을 쓰는 앱은 @plott/webkit-sanitize 를 직접 설치하고, 그 밖의 이름만 쓰는 앱은 설치하지 않습니다(@plott/webkit-react 만 설치한 앱은 HTML 파서를 받지 않습니다). React 에 묶이지 않아 Capacitor 셸·바닐라 화면에서도 씁니다.

진입점

묶음이름하는 일
한글 입력onEnter, isSubmitEnter, shouldSubmitOnEnter, isComposing조합 중 Enter 를 거르고, 폼 입력에서 Enter 를 제출로 볼지 정한다
화면 키보드watchViewport, subscribeViewport, readViewport키보드가 가린 영역을 CSS 변수로, 또는 값만 받는다
업로드uploadPresignedPost, uploadPresignedPut, UploadErrorpresigned 주소로 올린다(진행률·취소·상태 검사)
파일checkFile, saveBlob형식·크기·실제 바이트 검사, Blob 저장
저장소safeStorage던지지 않는 localStorage·sessionStorage
외부 화면 복귀markHistory, returnToMark결제창·본인 확인에서 돌아온 뒤 «뒤로» 를 바로잡는다

붙이기

ts
import { onEnter } from "@plott/webkit-browser";
declare const addTag: (value: string) => void;declare const value: string;
export const onKeyDown = onEnter(() => addTag(value));
ts
import { watchViewport } from "@plott/webkit-browser";
export const stopWatching = watchViewport();

여러 컴포넌트가 같은 상태를 보면 subscribeViewport 로 값만 받습니다. <html> 에 --vv-* 변수와 data-keyboard 속성을 쓰지 않고, 처음 값과 바뀐 값만 listener 로 옵니다. watchViewport 는 이 구독 위에서 변수와 속성을 씁니다.

ts
import { subscribeViewport } from "@plott/webkit-browser";
declare const setInset: (bottom: number) => void;
export const stopSubscribing = subscribeViewport((state) => setInset(state.bottom));

폼 안 입력에서 Enter 를 제출로 볼지는 shouldSubmitOnEnter(event) 로 정합니다.

ts
import { shouldSubmitOnEnter } from "@plott/webkit-browser";
declare const submit: () => void;
export const onKeyDown = (e: { key: string; target: { tagName?: string } | null; preventDefault(): void }) => {  if (!shouldSubmitOnEnter(e)) return;  e.preventDefault();  submit();};
css
.sheet { position: fixed; bottom: var(--vv-bottom, 0); max-height: var(--vv-height, 92dvh) }html[data-keyboard="open"] .sheet { padding-bottom: 0 }

업로드 정책(issueUploadPolicy)은 앱이 자기 인증으로 서버에서 받습니다.

ts
import { checkFile, uploadPresignedPost, type FileProblem, type PresignedPostPolicy } from "@plott/webkit-browser";
declare const file: File;declare const signal: AbortSignal;declare const t: { upload: Record<FileProblem["reason"], string> };declare const toast: (message: string) => void;declare const setProgress: (ratio: number) => void;declare const issueUploadPolicy: (file: File) => Promise<PresignedPostPolicy>;
export async function upload() {  const problem = await checkFile(file, { accept: ["image/*"], maxBytes: 10 * 1024 * 1024 });  if (problem) return toast(t.upload[problem.reason]);  const policy = await issueUploadPolicy(file);  await uploadPresignedPost(policy, file, { onProgress: ({ loaded, total }) => setProgress(loaded / total), signal });}

markHistory 는 결제창으로 떠나기 직전에, returnToMark 는 결제 완료·취소로 돌아온 화면에서 부릅니다.

ts
import { markHistory, returnToMark } from "@plott/webkit-browser";
declare const router: { replace(href: string): void };
markHistory("payment");if (!returnToMark("payment")) router.replace("/checkout/done");

기본으로 지키는 것

  • 한글 조합 중 Enter 를 거릅니다. 조합을 끝내는 Enter 가 한 번 더 와서 태그가 두 개 생기거나 문장이 둘로 저장됩니다. React 이벤트의 nativeEvent.isComposing 과 keyCode 229 를 봅니다. Safari 는 조합을 끝내는 Enter 를 key: "Enter", isComposing: false, keyCode: 229 로 보내므로 229 는 늘 조합으로 봅니다. 모든 keydown 에 229 를 주는 안드로이드 키보드를 받아야 하는 화면은 onEnter(handler, { trust229: false }) 로 끕니다 — 그때는 229 를 key 가 Process·Unidentified(또는 없음)일 때만 조합으로 봅니다. Shift+Enter 는 줄바꿈이라 지나갑니다. shouldSubmitOnEnter 는 여기에 Ctrl·Meta·Alt 를 함께 누른 Enter 와 textarea(event.target.tagName)의 Enter 도 제출로 보지 않습니다. onEnter 의 handler 가 false 를 돌려주면 preventDefault 를 하지 않습니다.
  • 키보드 높이가 아니라 보이는 영역의 아랫변을 알립니다. iOS 는 키보드가 열려도 고정 요소의 기준을 줄이지 않고 보이는 영역만 끌어올려, 키보드 높이만큼 올린 시트는 입력칸이 잘립니다. 보이는 높이가 150px 이상 줄 때만 키보드로 봅니다(주소창 접힘·핀치 확대·포커스만으로는 열지 않습니다). watchViewport(200) 처럼 숫자만 주면 threshold 입니다. 변수 이름 앞머리(cssPrefix, 기본 --vv-)와 키보드 속성 이름(attribute, 기본 data-keyboard)은 옵션으로 바꿉니다. 잘못된 이름(-- 로 시작하지 않는 접두사, 속성 이름이 아닌 값)은 @plott/webkit-browser: 로 시작하는 TypeError 입니다. 안드로이드 adjustResize 는 창(innerHeight)도 함께 줄어 위 비교로는 차이가 없으므로, 가로 폭(방향)마다 본 가장 큰 innerHeight 와도 견줍니다 — 입력칸(input·textarea·select·contenteditable)에 포커스가 있을 때만. 한계: 기준은 페이지를 연 뒤 본 값이라 키보드가 열린 채로 열린 화면에서는 알 수 없고, 입력칸 포커스 중에 창이 줄어드는 분할 화면·창 크기 조절은 키보드로 오인할 수 있습니다.
  • 업로드는 응답 상태를 봅니다. 2xx 가 아니면 UploadError(상태·본문, kind: http·network·timeout)입니다. presigned POST 는 필드를 먼저, 파일을 마지막에 넣습니다(S3 는 파일 뒤의 필드를 버립니다). 필드 순서가 중요하면 객체 대신 [이름, 값][] 배열로 주세요(객체는 "10"·"2" 같은 정수 키를 숫자 순으로 앞에 당깁니다). PUT 의 Content-Type 은 서명할 때와 같아야 하고, 헤더 이름은 대소문자를 가리지 않고 하나만 보냅니다(우선순위 headers > contentType > file.type). timeoutMs 를 주면 그 시간 뒤 kind: "timeout" 으로 끝나고, 요청이 끝나거나 xhr 의 open·setRequestHeader·send 가 던지면 signal 의 abort 리스너를 치우고, 던진 오류로 거절합니다. xhr 옵션으로 XMLHttpRequest 를 바꿔 끼울 수 있습니다(테스트용).
  • 파일 형식은 확장자·MIME 만 믿지 않습니다. png·jpg·gif·webp·pdf 는 확장자나 MIME 이 그렇게 말하면 첫 바이트가 맞아야 통과합니다(이름만 .png 인 HTML, 확장자 없는 photo + image/png 인 HTML, 확장자와 MIME 이 서로 다른 형식을 말하는 파일을 막습니다). image/* 는 image/svg+xml 을 받지 않습니다 — 스크립트를 품을 수 있어, 받으려면 accept 에 따로 적습니다. 종류를 가릴 수 없는 이미지(bmp·heic 등)도 내용이 마크업이면 막습니다. 빈 파일도 막습니다. 확장자·MIME 을 기존 종류에 더 이어 붙이려면 checkFile(file, { extraKinds: { extensions: { jfif: "jpeg" }, mimeTypes: { "image/pjpeg": "jpeg" } } }) 로 적습니다(종류는 png·jpeg·gif·webp·pdf·zip, 조회는 자기 키만 봅니다).
  • @plott/webkit-sanitize 가 없어도 checkFile 밖은 그대로 돕니다. 선택 peer 라서 설치하지 않으면 내용 검사를 하는 checkFile 만 @plott/webkit-browser: 로 시작하는 Error(설치하거나 verifyContent: false 를 주라는 안내, cause 에 원래 오류)로 거절합니다. 빈 파일·크기·accept 검사와 verifyContent: false 는 설치와 상관없이 통과합니다.
  • 저장소가 막혀도 던지지 않습니다. 사파리 개인 창·차단된 사이트 데이터에서는 메모리로 동작합니다(persistent: false). 용량이 가득 찬 저장소는 «없는 저장소» 가 아니라서 기존 값은 계속 읽히고 set 만 false 를 돌려줍니다. 깨진 JSON 은 null 이고, setJson 은 JSON 으로 만들 수 없는 값(순환 참조·BigInt·undefined·함수)에 false 를 돌려줍니다. remove 는 저장소가 지우지 못해도 던지지 않습니다.
  • 외부 화면에서 돌아오면 떠나기 전 위치까지 뒤로 갑니다. history.replace 는 다른 오리진의 기록을 지우지 못해 «뒤로» 에 결제창의 «세션이 끝났습니다» 가 나옵니다. markHistory 는 실제로 저장했을 때만 true 입니다 — 저장소가 막혔거나 가득 차면 false 이니 그때는 돌아와서 replace 로 자리를 정합니다. 기록을 셀 수 없으면(길이가 maxHistory 이상 — 기본 50(크롬), 사파리는 returnToMark(key, { maxHistory: 100 }) —, 표시 없음, 30분 지남) returnToMark 가 false 를 돌려주고 화면이 replace 로 자리를 정합니다. 숫자만 주면 maxAgeMs 입니다. 표시 키는 webkit:history-mark: 로 시작하고 markHistory(key, { prefix })·returnToMark(key, { prefix }) 로 바꿉니다(두 쪽에 같은 값을 줍니다).
  • saveBlob 은 링크를 문서에 붙였다가 누르고 30초 뒤에 해제합니다(saveBlob(blob, name, { revokeAfterMs }) 로 바꿉니다). 붙이지 않은 링크의 download 를 무시하거나, 바로 해제하면 내려받기가 끊기는 브라우저가 있습니다. 앱 WebView 는 download 를 처리하지 못하는 경우가 많아 브릿지의 저장 동작을 씁니다.