Implements wiki-style image syntax ![[path]] for md-editor-rt, including S3 upload handler, markdown-it parsing, IndexedDB-based pre-signed URL caching, and client-side hydration. Use when wiring image uploads or rendering custom image syntax in MemoEditor/MemoViewer or new md-editor-rt instances.
md-editor-rt에서 표준 ![]() 대신 위키 스타일 이미지 문법 ![[path]] 를 사용하기 위한 패턴을 정의한다.![[path]] 형태만 남겨 경로 추상화를 유지한다.![[path]] 를 실제 Pre-signed URL 로 치환하기 위해 IndexedDB 캐시 + Hydration 패턴을 사용한다.images/20250310/uuid_filename.png)![[path]] 를 사용하면:
이 스킬은 다음 네 가지 단계를 다룬다.
onUploadImg![[path]]![[path]] 를 현재 커서 위치에 삽입markdown-it 커스텀 룰로 ![[path]] 를 토큰/img 태그로 변환md-editor-rt의 onUploadImg 콜백을 사용해 파일을 가로채고 직접 S3 업로드 후, ![[path]] 형식의 문자열을 되돌려준다.callback)은 보통 표준 ![]() 마크다운을 기대하지만, 이 프로젝트에서는 ![[path]] 문자열을 그대로 삽입하는 패턴을 사용한다.const onUploadImg = async (files: File[], callback: (values: string[]) => void) => {
const results = await Promise.all(
files.map((file) => {
return new Promise<string>((resolve, reject) => {
uploadToS3(file)
.then((path) => {
// path: S3 bucket 하위 Object Key (예: images/20250310/uuid_xxx.png)
resolve(`![[${path}]]`);
})
.catch(reject);
});
}),
);
// md-editor-rt에 직접 삽입되도록 문자열 배열을 넘긴다.
callback(results);
};
images/YYYYMMDD/uuid_filename.ext
![[...]] 문법을 삽입하지 않도록 한다.![[path]] 패턴을 찾아서:
src 가 비어 있거나 임시 로더 이미지를 가진 img 태그src 에는 실제 Pre-signed URL을 바로 넣지 않고, path를 data 속성 등으로 보관한다.아래는 개략적인 형태(실제 프로젝트 코드에서는 타입/모듈 구조에 맞게 조정한다).
// markdown-it 플러그인 예시 (개념용)
function wikiImagePlugin(md: MarkdownIt) {
const WIKI_IMAGE_RE = /!\[\[([^[\]]+)\]\]/g;
md.core.ruler.push('wiki-image', (state) => {
state.tokens.forEach((blockToken) => {
if (blockToken.type !== 'inline' || !blockToken.children) return;
const children: any[] = [];
blockToken.children.forEach((token) => {
if (token.type !== 'text') {
children.push(token);
return;
}
const text = token.content;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = WIKI_IMAGE_RE.exec(text)) !== null) {
const path = match[1].trim();
// 앞쪽 일반 텍스트 유지
if (match.index > lastIndex) {
const t = new state.Token('text', '', 0);
t.content = text.slice(lastIndex, match.index);
children.push(t);
}
// ![[path]] -> img 토큰(또는 커스텀 토큰)으로 변환
const imgToken = new state.Token('wiki_image', 'img', 0);
imgToken.attrSet('data-wiki-path', path);
imgToken.attrSet('src', ''); // Hydration 단계에서 채움
children.push(imgToken);
lastIndex = match.index + match[0].length;
}
// 남은 텍스트
if (lastIndex < text.length) {
const t = new state.Token('text', '', 0);
t.content = text.slice(lastIndex);
children.push(t);
}
});
blockToken.children = children;
});
});
}
wiki_image 토큰을 <img> 태그로 출력하고, data-wiki-path 속성을 남겨둔다.data-wiki-path 를 읽어 URL 캐시/요청 결과를 src 에 주입한다.IndexedDB에 다음과 같은 구조의 레코드를 저장한다.
| 필드명 | 타입 | 설명 |
|---|---|---|
path | String (PK) | S3 Object Key (이미지 경로) |
url | String | Pre-signed URL 또는 public URL |
expires | Number | 초 단위 만료 시간 (-1이면 영구) |
createdAt | Number | 캐시 생성 시각 (ms, Date.now()) |
type ImageCacheItem = {
path: string;
url: string;
expires: number; // seconds, -1 = never
createdAt: number; // ms
};
const isCacheValid = (item: ImageCacheItem) => {
if (item.expires === -1) return true;
const now = Date.now();
const expireTimeMs = item.createdAt + item.expires * 1000;
return now < expireTimeMs;
};
data-wiki-path 를 가진 모든 이미지 후보를 수집한다.url 을 img.src 에 설정한다.{ path, url, expires } 들을 IndexedDB에 저장하고, 각 DOM img 의 src 를 업데이트한다.md-editor-rt/markdown-it 엔진은 동기적으로 마크다운을 HTML로 변환한다.src="" 또는 로딩용 이미지로 두고,useEffect 나 DOM 스캔을 통해 나중에 URL을 주입해야 한다.MdPreview 렌더 후, useEffect에서 document.querySelectorAll('[data-wiki-path]') 로 이미지들을 찾아 Hydration 로직을 실행한다.editorId 등을 이용해 특정 컨테이너 아래만 스캔하는 패턴을 선호할 수 있다.실제 구현에서는 이 스킬의 개념을 따라,
- DOM 스캔 범위 (
#${editorId}이하 등)- API 호출 함수 (
fetchPresignedUrls(paths: string[]))
를 프로젝트 코드에 맞게 구체화한다.
img에 onerror 핸들러를 붙여, 로드 실패 시 서버에서 해당 path에 대한 새 signed URL을 받아 src를 갱신하고 재시도한다.패턴 요약
img.src를 설정한 뒤, 해당 img에 onerror를 등록한다.onerror 발생 시 data-wiki-path에서 path를 읽는다.fetchPresignedUrls([path]) 또는 단일 path 전용 API)img.src에 넣는다. 브라우저가 자동으로 재요청한다.onerror를 제거해 추가 요청을 막는다.// Hydration 시 각 img에 적용하는 예시
function bindWikiImage(img: HTMLImageElement, path: string) {
const MAX_RETRIES = 1;
let retryCount = 0;
const setSrc = (url: string) => {
img.src = url;
};
const loadWithFreshUrl = async () => {
if (retryCount >= MAX_RETRIES) return;
retryCount += 1;
const [item] = await fetchPresignedUrls([path]); // 단일 path 배열로 호출
if (item?.url) {
await saveToImageCache(item); // IndexedDB 갱신
setSrc(item.url);
}
};
img.onerror = () => {
loadWithFreshUrl();
};
// 최초 Hydration: 캐시/API로 받은 url을 setSrc로 설정한 뒤, 위 onerror가 실패 시 재시도 담당
resolveUrl(path).then((url) => {
if (url) setSrc(url);
});
}
onerror가 여러 img에서 동시에 발생할 수 있으므로, fetchPresignedUrls 내부에서 in-flight 맵으로 단일 요청만 보내도록 한다.inFlight[path] = Promise 형태의 맵을 두고, 이미 요청 중인 경우 기존 Promise를 재사용하는 패턴이 권장된다.createdAt 기준 N일 이상 경과)을 주기적으로 삭제새로운 md-editor-rt 에디터/뷰어에서 ![[path]] 이미지 문법을 사용할 때:
onUploadImg 에서 S3 업로드 후 ![[path]] 문자열을 생성해 callback에 넘긴다.markdown-it 커스텀 룰로 ![[path]] 를 인식해 data-wiki-path 를 가진 img 또는 전용 토큰으로 변환한다.data-wiki-path 목록을 수집하고, IndexedDB 캐시를 먼저 확인한다.onerror: 로드 실패 시 data-wiki-path로 서버에서 새 signed URL을 받아 src에 설정하고, 캐시를 갱신한다. 재시도 횟수 제한으로 무한 루프를 방지한다.