Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 43 additions & 35 deletions rhwp-studio/src/command/commands/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,12 @@ import { PageSetupDialog } from '@/ui/page-setup-dialog';
import { AboutDialog } from '@/ui/about-dialog';
import { showConfirm } from '@/ui/confirm-dialog';
import { showSaveAs } from '@/ui/save-as-dialog';

// File System Access API (Chrome/Edge)
declare global {
interface Window {
showSaveFilePicker?: (options?: {
suggestedName?: string;
types?: { description: string; accept: Record<string, string[]> }[];
}) => Promise<FileSystemFileHandle>;
}
}
import {
pickOpenFileHandle,
readFileFromHandle,
saveDocumentToFileSystem,
type FileSystemWindowLike,
} from '@/command/file-system-access';

export const fileCommands: CommandDef[] = [
{
Expand All @@ -36,8 +32,25 @@ export const fileCommands: CommandDef[] = [
{
id: 'file:open',
label: '열기',
execute() {
document.getElementById('file-input')?.click();
async execute(services) {
try {
const handle = await pickOpenFileHandle(window as FileSystemWindowLike);
if (!handle) {
document.getElementById('file-input')?.click();
return;
}

const { bytes, name } = await readFileFromHandle(handle);
services.eventBus.emit('open-document-bytes', {
bytes,
fileName: name,
fileHandle: handle,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[file:open] 열기 실패:', msg);
alert(`파일 열기에 실패했습니다:\n${msg}`);
}
},
},
{
Expand All @@ -53,34 +66,29 @@ export const fileCommands: CommandDef[] = [
const isHwpx = sourceFormat === 'hwpx';
const bytes = isHwpx ? services.wasm.exportHwpx() : services.wasm.exportHwp();
const mimeType = isHwpx ? 'application/hwp+zip' : 'application/x-hwp';
const ext = isHwpx ? '.hwpx' : '.hwp';
const blob = new Blob([bytes as unknown as BlobPart], { type: mimeType });
console.log(`[file:save] format=${sourceFormat}, isHwpx=${isHwpx}, ${bytes.length} bytes`);

// 1) File System Access API 지원 시 네이티브 저장 대화상자 사용
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker!({
suggestedName: saveName,
types: [{
description: isHwpx ? 'HWPX 문서' : 'HWP 문서',
accept: isHwpx
? { 'application/hwp+zip': ['.hwpx'] }
: { 'application/x-hwp': ['.hwp'] },
}],
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
services.wasm.fileName = handle.name;
console.log(`[file:save] ${handle.name} (${(bytes.length / 1024).toFixed(1)}KB)`);
// 1) 기존 파일 handle이 있으면 같은 파일에 저장, 없으면 save picker 시도
try {
const saveResult = await saveDocumentToFileSystem({
blob,
suggestedName: saveName,
currentHandle: services.wasm.currentFileHandle,
windowLike: window as FileSystemWindowLike,
});

if (saveResult.method !== 'fallback') {
services.wasm.currentFileHandle = saveResult.handle;
services.wasm.fileName = saveResult.fileName;
console.log(`[file:save] ${saveResult.fileName} (${(bytes.length / 1024).toFixed(1)}KB)`);
return;
} catch (e) {
// 사용자가 취소하면 AbortError 발생 — 무시
if (e instanceof DOMException && e.name === 'AbortError') return;
// 그 외 오류는 폴백으로 진행
console.warn('[file:save] File System Access API 실패, 폴백:', e);
}
} catch (e) {
// 사용자가 취소하면 AbortError 발생 — 무시
if (e instanceof DOMException && e.name === 'AbortError') return;
// 그 외 오류는 폴백으로 진행
console.warn('[file:save] File System Access API 실패, 폴백:', e);
}

// 2) 폴백: 새 문서인 경우 자체 파일이름 대화상자 표시
Expand Down
112 changes: 112 additions & 0 deletions rhwp-studio/src/command/file-system-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
export interface FileSystemWritableFileStreamLike {
write(data: Blob): Promise<void>;
close(): Promise<void>;
}

export interface FileSystemFileHandleLike {
kind?: 'file';
name: string;
getFile(): Promise<File>;
createWritable(): Promise<FileSystemWritableFileStreamLike>;
}

export interface FileSystemWindowLike {
showOpenFilePicker?: (options?: {
excludeAcceptAllOption?: boolean;
multiple?: boolean;
types?: { description: string; accept: Record<string, string[]> }[];
}) => Promise<FileSystemFileHandleLike[]>;
showSaveFilePicker?: (options?: {
suggestedName?: string;
types?: { description: string; accept: Record<string, string[]> }[];
}) => Promise<FileSystemFileHandleLike>;
}

export interface FileHandleReadResult {
name: string;
bytes: Uint8Array;
}

export interface SaveDocumentOptions {
blob: Blob;
suggestedName: string;
currentHandle: FileSystemFileHandleLike | null;
windowLike: FileSystemWindowLike;
}

export interface SaveDocumentResult {
method: 'current-handle' | 'save-picker' | 'fallback';
handle: FileSystemFileHandleLike | null;
fileName: string;
}

const HWP_PICKER_TYPES = [{
description: 'HWP 문서',
accept: { 'application/x-hwp': ['.hwp', '.hwpx'] },
}];

function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}

async function writeBlobToHandle(handle: FileSystemFileHandleLike, blob: Blob): Promise<void> {
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
}

export async function pickOpenFileHandle(windowLike: FileSystemWindowLike): Promise<FileSystemFileHandleLike | null> {
if (!windowLike.showOpenFilePicker) return null;

try {
const handles = await windowLike.showOpenFilePicker({
excludeAcceptAllOption: true,
multiple: false,
types: HWP_PICKER_TYPES,
});
return handles[0] ?? null;
} catch (error) {
if (isAbortError(error)) return null;
throw error;
}
}

export async function readFileFromHandle(handle: FileSystemFileHandleLike): Promise<FileHandleReadResult> {
const file = await handle.getFile();
return {
name: file.name,
bytes: new Uint8Array(await file.arrayBuffer()),
};
}

export async function saveDocumentToFileSystem(options: SaveDocumentOptions): Promise<SaveDocumentResult> {
const { blob, suggestedName, currentHandle, windowLike } = options;

if (currentHandle) {
await writeBlobToHandle(currentHandle, blob);
return {
method: 'current-handle',
handle: currentHandle,
fileName: currentHandle.name,
};
}

if (windowLike.showSaveFilePicker) {
const handle = await windowLike.showSaveFilePicker({
suggestedName,
types: HWP_PICKER_TYPES,
});
await writeBlobToHandle(handle, blob);
return {
method: 'save-picker',
handle,
fileName: handle.name,
};
}

return {
method: 'fallback',
handle: null,
fileName: suggestedName,
};
}
12 changes: 12 additions & 0 deletions rhwp-studio/src/core/wasm-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import init, { HwpDocument, version } from '@wasm/rhwp.js';
import type { DocumentInfo, PageInfo, PageDef, SectionDef, CursorRect, HitTestResult, LineInfo, TableDimensions, CellInfo, CellBbox, CellProperties, TableProperties, DocumentPosition, MoveVerticalResult, SelectionRect, CharProperties, ParaProperties, CellPathEntry, NavContextEntry, FieldInfoResult, BookmarkInfo } from './types';
import { resolveFont, fontFamilyWithFallback } from './font-substitution';
import { REGISTERED_FONTS } from './font-loader';
import type { FileSystemFileHandleLike } from '@/command/file-system-access';

/**
* CSS font 문자열에서 font-family를 추출하여 폰트 치환을 적용한다.
Expand Down Expand Up @@ -32,6 +33,7 @@ export class WasmBridge {
private doc: HwpDocument | null = null;
private initialized = false;
private _fileName = 'document.hwp';
private _currentFileHandle: FileSystemFileHandleLike | null = null;

async initialize(): Promise<void> {
if (this.initialized) return;
Expand Down Expand Up @@ -64,6 +66,7 @@ export class WasmBridge {
this.doc.free();
}
this._fileName = fileName ?? 'document.hwp';
this._currentFileHandle = null;
this.doc = new HwpDocument(data);
this.doc.convertToEditable();
this.doc.setFileName(this._fileName);
Expand All @@ -79,6 +82,7 @@ export class WasmBridge {
}
const info: DocumentInfo = JSON.parse(this.doc.createBlankDocument());
this._fileName = '새 문서.hwp';
this._currentFileHandle = null;
this.doc.setFileName(this._fileName);
console.log(`[WasmBridge] 새 문서 생성: ${info.pageCount}페이지`);
return info;
Expand All @@ -92,6 +96,14 @@ export class WasmBridge {
this._fileName = name;
}

get currentFileHandle(): FileSystemFileHandleLike | null {
return this._currentFileHandle;
}

set currentFileHandle(handle: FileSystemFileHandleLike | null) {
this._currentFileHandle = handle;
}

get isNewDocument(): boolean {
return this._fileName === '새 문서.hwp';
}
Expand Down
24 changes: 21 additions & 3 deletions rhwp-studio/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,9 +435,7 @@ async function loadFile(file: File): Promise<void> {
msg.textContent = '파일 로딩 중...';
const startTime = performance.now();
const data = new Uint8Array(await file.arrayBuffer());
const docInfo = wasm.loadDocument(data, file.name);
const elapsed = performance.now() - startTime;
await initializeDocument(docInfo, `${file.name} — ${docInfo.pageCount}페이지 (${elapsed.toFixed(1)}ms)`);
await loadBytes(data, file.name, null, startTime);
} catch (error) {
const errMsg = `파일 로드 실패: ${error}`;
msg.textContent = errMsg;
Expand All @@ -447,6 +445,18 @@ async function loadFile(file: File): Promise<void> {
}
}

async function loadBytes(
data: Uint8Array,
fileName: string,
fileHandle: typeof wasm.currentFileHandle,
startTime = performance.now(),
): Promise<void> {
const docInfo = wasm.loadDocument(data, fileName);
wasm.currentFileHandle = fileHandle;
const elapsed = performance.now() - startTime;
await initializeDocument(docInfo, `${fileName} — ${docInfo.pageCount}페이지 (${elapsed.toFixed(1)}ms)`);
}

async function createNewDocument(): Promise<void> {
const msg = sbMessage();
try {
Expand All @@ -461,6 +471,14 @@ async function createNewDocument(): Promise<void> {

// 커맨드에서 새 문서 생성 호출
eventBus.on('create-new-document', () => { createNewDocument(); });
eventBus.on('open-document-bytes', async (payload) => {
const data = payload as {
bytes: Uint8Array;
fileName: string;
fileHandle: typeof wasm.currentFileHandle;
};
await loadBytes(data.bytes, data.fileName, data.fileHandle);
});

// 수식 더블클릭 → 수식 편집 대화상자
eventBus.on('equation-edit-request', () => {
Expand Down
Loading