Edit me

파일을 읽고 저장하자.

파일 원본 보존하기

파일이 새로 열리게 되면 위치와 내용을 저장해둔다. 이는 수정 후 저장하지 않을 경우 복구하기 위함이다. markdownView는 text를 입력할 수 있는 textarea의 id이다. textarea에 내용을 업데이트하고 markdown을 html로 변환하는 함수인 renderMarkdownToHtml을 호출한다.

// renderer.js
ipcRenderer.on('file-opened', (event, file, content) => {
    filePath = file; 
    originalContent = content;

    markdownView.value = content;
    renderMarkdownToHtml(content);
    updateUserInterface();
});

윈도우 창 제목 변경하기

새로운 파일이 Open되면 파일 이름으로 윈도우 창 이름을 변경한다. currentWin는 main process에게 얻은 BrowserWindow 인스턴스이다.

// renderer.js
const updateUserInterface = (isEdited) => {
    let title = 'Fire Sale';
    if (filePath) { title = `${path.basename(filePath)} - ${title}`; }
    if (isEdited) { title = `${title} (Edited)`; }

    currentWin.setTitle(title);
    currentWin.setDocumentEdited(isEdited);

    saveMarkdownButton.disabled = !isEdited; 
    revertButton.disabled = !isEdited; 
};

path.basename은 긴 파일 path에서 파일명만 추출해주는 함수이다. 추출된 파일이름과 앱이름을 조합하여 title을 생성한다. 그리고 setTitle 함수를 통해 제목을 설정한다. 그리고 파일 수정이 없었으므로 save button을 disable 한다.

// renderer.js
markdownView.addEventListener('keyup', (event) => {
    const currentContent = event.target.value;
    renderMarkdownToHtml(currentContent);
    updateUserInterface(currentContent !== originalContent);
});

keyup event를 등록한다. 키보드의 버튼이 입력될때마다 markdown은 html로 변환되고 edited flag를 업데이트한다. 그러면 save 버튼이 활성화 된다.

파일 저장하기

파일을 저장할 수 있는 권한은 main process에 있다. 따라서 저장할 데이터를 renderer process에서 main process에 전달하고 main process는 해당 데이터를 저장한다. 여기서는 markdown과 html 두가지 타입으로 저장하는 방법을 제공한다.

// main.ts
const saveHtml = exports.saveHtml = async (targetWindow: any, content: string) => {
  const files = await dialog.showSaveDialog(targetWindow, {
    title: 'Save HTML',
    defaultPath: app.getPath('documents'),
    filters: [
      { name: 'HTML Files', extensions: ['html', 'htm'] }
    ]
  });
  if (!files) return;
  fs.writeFileSync(files.filePath, content);
};

showSaveDialog는 저장을 위한 dialog이다.
다음 옵션을 지정할 수 있다.

  • title: dialog의 제목을 지정한다. 하지만 macOS는 나타나지 않는다.
  • defaultPath: 저장을 위한 기본 디렉토리를 지정한다.
  • buttonLabel: 저장 버튼에 커스텀 Text를 지원한다.
  • filters: 파일 종류를 선택하여 해당 타입만 보이도록 한다.

app.getPath는 os에서 제공하는 기본 path를 의미한다. 'documents'는 윈도우에서는 내 폴더, mac에서는 Documents를 의미한다. 그 외에도 추가 path를 제공한다.

// renderer.js
saveHtmlButton.addEventListener('click', () => {
    mainWin.saveHtml(currentWin, htmlView.innerHTML);
});

renderer process에서는 html파일을 저장하기 위해 htmlView에서 contents를 가져와 main process에게 전송한다.
markdown 파일도 크게 다르지 않다.

// main.ts
const saveMarkdown = exports.saveMarkdown = async (targetWindow: any, file: string, content: string) => {
  if (!file) {
    const files = await dialog.showSaveDialog(targetWindow, {
      title: 'Save Markdown',
      defaultPath: app.getPath('documents'),
      filters: [
        { name: 'Markdown Files', extensions: ['md', 'markdown'] }
      ]
    });

    if (!files) return;
    fs.writeFileSync(files.filePath, content);
    openFile(targetWindow, files.filePath);
  }
};

다른점은 openFile을 재호출하고 있는데 이는 os의 최근 문서나 현재 열린 문서에 대한 정보를 업데이트하기 위해 호출한다.

// renderer.js
saveMarkdownButton.addEventListener('click', () => {
    mainWin.saveMarkdown(currentWin, filePath, markdownView.value);
});

Reverting files

Revert를 하면 처음 Open했었을 때 저장한 Contents Data를 불러온다.
수정중에 아차 싶으면 다시 Revert 버튼을 누른다. Undo는 아니고 초기화에 가까운 기능이다.

// renderer.js
revertButton.addEventListener('click', () => {
    markdownView.value = originalContent;
    renderMarkdownToHtml(originalContent);
});

Drag and Drop

파일을 Open할 때 메뉴를 이용하여 Open하는 방법이 일반적이지만 가끔 파일을 드레그하여 Open하는 경우도 있다. 이 기능을 구현하도록한다.
grag&drop 관련 Event를 지원하고 있기 때문에 해당 Event에 연결만 하면 된다.

  • dragstart
  • dragover
  • dragleave
  • drop

먼저 window 창의 top event를 비활성화 해야한다. 기본 액션은 웹브라우저를 기준으로 하고 있기 때문이다.

// renderer.js
document.addEventListener('dragstart', event => event.preventDefault());
document.addEventListener('dragover', event => event.preventDefault());
document.addEventListener('dragleave', event => event.preventDefault());
document.addEventListener('drop', event => event.preventDefault());

그리고 style.css를 통해 visual feedback을 제공한다. 파일을 드래그하여 해당 창위에 올려놨을 때 테두리나 배경색을 변경하여 맞는 타입의 파일을 드래그하고 있는 지의 정보를 제공한다.

/* style.css */
.raw-markdown.drag-over {
    background-color: rgb(181, 220, 216);
    border-color: rgb(75, 160, 151);
}

.raw-markdown.drag-error {
    background-color: rgba(170, 57, 57, 1);
    border-color: rgba(255, 170, 170, 1);
}

Helper 함수를 작성한다.
Event를 받아 dataTransfer 구조체를 통해 원하는 정보를 구한다. 배열 첫번째로 되어 있는 이유는 다수의 파일을 드레그할 수 있기 때문이다. 이 예제에서는 다수의 파일에 대해서 처리하지 않는다.

// renderer.js
const getDraggedFile = (event) => event.dataTransfer.items[0];
const getDroppedFile = (event) => event.dataTransfer.files[0];
const fileTypeIsSupported = (file) => {
    return ['text/plain', 'text/markdown'].includes(file.type);
};

이제 dragoverdragleave event를 작성한다. dragover는 마우스로 파일을 윈도우 창에 올리는 행동을 한 경우이다. 이 경우 앞서 작성한 style 태그가 적용될 수 있도록 한다. 그리고 dragleave이벤트는 떠났을 때이고 이 때 style 태그를 제거한다. dom객체의 classList는 style 태그의 속성을 가리킨다.

// renderer.js
markdownView.addEventListener('dragover', (event) => {
    const file = getDraggedFile(event);
    if (fileTypeIsSupported(file)) {
        markdownView.classList.add('drag-over');
    } else {
        markdownView.classList.add('drag-error');
    }
});
markdownView.addEventListener('dragleave', () => {
    markdownView.classList.remove('drag-over');
    markdownView.classList.remove('drag-error');
});

그리고 dragdrop 이벤트를 작성한다. 파일 형식이 맞다면 해당 파일을 Open하도록 하고 그렇지 않다면 alert창을 통해 알려준다.

// renderer.js
markdownView.addEventListener('drop', (event) => {
    const file = getDroppedFile(event);
    if (fileTypeIsSupported(file)) {
        mainWin.openFile(currentWin, file.path);
    } else {
        alert('That file type is not supported');
    }
    markdownView.classList.remove('drag-over');
    markdownView.classList.remove('drag-error');
});

파일 수정 감지하기

파일을 수정하다보면 다른 tool에 의해 파일이 수정되는 경우가 발생한다. 그럴 경우 수정된 파일로 업데이트를 한다. 그런데 현재 본인이 직접 수정하고 있는데 다른 앱으로 인해 수정되어 덮어쓰여지는 사고가 발생할 수 있다. 그럴땐 alert 창으로 확인하는 코드를 넣으면 된다. 일단 그냥 업데이트 하는 코드를 살펴 본다.

// main.ts
const openFiles = new Map();
const startWatchingFile = (targetWindow: any, file: string) => {
  stopWatchingFile(targetWindow);
  const watcher = fs.watchFile(file, (event: any) => {
    if (event === 'change') {
      const content = fs.readFileSync(file);
      targetWindow.webContents.send('file-opened', file, content);
    }
  });
  openFiles.set(targetWindow, watcher);
};

const stopWatchingFile = (targetWindow: any) => {
  if (openFiles.has(targetWindow)) {
    openFiles.get(targetWindow).stop();
    openFiles.delete(targetWindow);
  }
};

stopWatchingFilecreateWindow 함수의 closed event시 호출되도록 한다. 그리고 openFile 함수에서 startWatchingFile을 호출하도록한다.

// main.ts
  newWindow.on('closed', () => {
    windows.delete(newWindow);
    stopWatchingFile(newWindow);
    newWindow = null;
  });

변경사항을 버리기 전에 물어보기

우리가 저장하지 않았는데 저장했다는 착각을 하고 실수로 종료 버튼을 누르면 그 동안 수정했던 문서가 날아갈 수 있다. 따라서 한번 더 물어보는 성의를 보여주자. close 이벤트가 발생했을 때 showMessageBox를 통해 확인한다. 이전과 다른점은 closeclosed라는 점이다. 이 두 이벤트는 트리거 되는 시점이 다르다.

// main.ts
  newWindow.on('close', (event: any) => {
    if (newWindow.isDocumentEdited()) {
      event.preventDefault();
      dialog.showMessageBox(newWindow, {
        type: 'warning',
        title: 'Quit with Unsaved Changes?',
        message: 'Your changes will be lost if you do not save.',
        buttons: [
          'Quit Anyway', 'Cancel',
        ],
        defaultId: 0,
        cancelId: 1
      }).then((result: any) => {
        if (result.response === 0) newWindow.destroy();
      });      
    }
  });

showMessageBox는 버튼들을 커스텀할 수 있으며 각 배열에 따라 버튼이 보여지게 된다. 해당 버튼을 클릭하게 되면 버튼의 index id가 리턴된다.
다음으로 renderer.js를 조금 리펙토링 해보자.

// renderer.js
const renderFile = (file, content) => {
    filePath = file;
    originalContent = content;
    markdownView.value = content;
    renderMarkdownToHtml(content);
    updateUserInterface(false);
};

renderFile함수는 랜더링에 필요한 함수들을 모아두었다. 이 함수는 file-openedfile-changed 이벤트에서 호출하도록 할 것이다.

// renderer.js
ipcRenderer.on('file-opened', (event, file, content) => {
    if (currentWindow.isDocumentEdited()) {
        const result = remote.dialog.showMessageBox(currentWindow, {
            type: 'warning',
            title: 'Overwrite Current Unsaved Changes?',
            message: 'Opening a new file in this window will overwrite your unsaved changes.Open this file anyway?',
            buttons: [
                'Yes',
                'Cancel',
            ],
            defaultId: 0,
            cancelId: 1
        });
        if (result === 1) { return; }
    }
    renderFile(file, content);
});

ipcRenderer.on('file-changed', (event, file, content) => {
    const result = remote.dialog.showMessageBox(currentWindow, {
        type: 'warning',
        title: 'Overwrite Current Unsaved Changes?',
        message: 'Another application has changed this file. Load changes?',
        buttons: [
            'Yes',
            'Cancel',
        ],
        defaultId: 0,
        cancelId: 1
    });
    renderFile(file, content);
});

이것으로 마무리한다.