From 9d425831d7d2be981ad7dec0e9c06f00ef75588a Mon Sep 17 00:00:00 2001 From: Mauro Bieg Date: Fri, 9 Apr 2021 13:13:01 +0200 Subject: [PATCH] Major refactorings (#57) - upgrade from Electron 9 to 12 - Remove all uses of the deprecated Electron remote module and replace with more secure IPC implementation. This also cleanly decouples the GUI app from the code that accesses the file system, and should enable us to eventually run PanWriter in a browser as well. - convert all PureScript files (which weren't that many) and most JavaScript files (which unfortunately had become many) to TypeScript - remove ability to read user-customizable css files (maybe we can add something like that in the future again, but will have to work with pandoc's document-css, which we'd need to parse properly) --- .gitignore | 8 +- README.md | 8 +- electron/file.ts | 82 + electron/ipc.ts | 54 + src/main.js => electron/main.ts | 301 +- electron/pandoc/export.ts | 311 + electron/pandoc/import.ts | 66 + electron/preload.ts | 59 + electron/recentFiles.ts | 44 + electron/tsconfig.json | 14 + {build/icons => icons}/icon-COPYING.txt | 0 {build/icons => icons}/icon.ico | Bin {build/icons => icons}/icon.png | Bin package.json | 81 +- psc-package.json | 14 - public/index.html | 15 + {static => public}/previewFrame.html | 0 {static => public}/previewFramePaged.html | 6 +- src/AppRenderer.purs | 20 - src/Data/Int/Parse.js | 6 - src/Data/Int/Parse.purs | 21 - src/Electron/CurrentWindow.js | 19 - src/Electron/CurrentWindow.purs | 8 - src/Electron/IpcRenderer.js | 13 - src/Electron/IpcRenderer.purs | 8 - src/Panwriter/App.purs | 154 - src/Panwriter/Button.purs | 21 - src/Panwriter/Document.js | 62 - src/Panwriter/Document.purs | 29 - src/Panwriter/File.js | 93 - src/Panwriter/File.purs | 14 - src/Panwriter/Formatter.purs | 16 - src/Panwriter/MetaEditor.purs | 212 - src/Panwriter/Preview.purs | 60 - src/Panwriter/Toolbar.purs | 133 - src/React/Basic/CodeMirror.js | 98 - src/React/Basic/CodeMirror.purs | 19 - src/React/Basic/ColorPicker.purs | 49 - src/React/Basic/PreviewRenderer.js | 206 - src/React/Basic/PreviewRenderer.purs | 20 - src/React/Basic/ReactColor.js | 16 - src/React/Basic/ReactColor.purs | 10 - src/appState/Action.ts | 28 + src/appState/AppState.ts | 36 + src/appState/appStateReducer.ts | 57 + .../assets}/preview.pandoc-styles.css | 11 +- static/app.css => src/components/App/App.css | 48 - src/components/App/App.tsx | 64 + src/components/Button.tsx | 13 + .../components/ColorPicker/ColorPicker.css | 0 src/components/ColorPicker/ColorPicker.tsx | 43 + .../components/Editor/Editor.css | 2 +- src/components/Editor/Editor.tsx | 94 + src/components/Editor/codemirror.css | 350 + .../components/MetaEditor/MetaEditor.css | 0 src/components/MetaEditor/MetaEditor.tsx | 172 + .../components/MetaEditor}/back.svg | 0 src/components/Preview/Preview.css | 47 + src/components/Preview/Preview.tsx | 30 + .../components/Toolbar/Toolbar.css | 0 src/components/Toolbar/Toolbar.tsx | 87 + .../Toolbar}/macOS_window_close.svg | 0 .../Toolbar}/macOS_window_maximize.svg | 0 .../Toolbar}/macOS_window_minimize.svg | 0 .../components/Toolbar}/metaEditor.svg | 0 {static => src/components/Toolbar}/notes.svg | 0 {static => src/components/Toolbar}/page.svg | 0 .../components/Toolbar}/vertical_split.svg | 0 .../components/Toolbar}/visibility.svg | 0 src/index.tsx | 16 + src/js/Document.js | 140 - src/js/Exporter.js | 275 - src/js/Importer.js | 53 - src/js/Renderers.js | 210 - src/js/renderer.js | 8 - src/js/rendererPreload.js | 10 - src/raw-loader.d.ts | 4 + src/react-app-env.d.ts | 1 + src/renderPreview/convertMd.ts | 57 + src/renderPreview/convertYaml.ts | 37 + src/renderPreview/renderPreview.ts | 55 + src/renderPreview/renderPreviewImpl.ts | 199 + src/renderPreview/scrolling.ts | 153 + src/renderPreview/templates/getCss.ts | 11 + .../templates/templates.ts} | 22 +- .../throttle.js => renderPreview/throttle.ts} | 18 +- static/index.html | 20 - static/preview.panwriter-default.css | 10 - tsconfig.json | 26 + yarn.lock | 13728 ++++++++++++++-- 90 files changed, 14457 insertions(+), 4048 deletions(-) create mode 100644 electron/file.ts create mode 100644 electron/ipc.ts rename src/main.js => electron/main.ts (50%) create mode 100644 electron/pandoc/export.ts create mode 100644 electron/pandoc/import.ts create mode 100644 electron/preload.ts create mode 100644 electron/recentFiles.ts create mode 100644 electron/tsconfig.json rename {build/icons => icons}/icon-COPYING.txt (100%) rename {build/icons => icons}/icon.ico (100%) rename {build/icons => icons}/icon.png (100%) delete mode 100644 psc-package.json create mode 100644 public/index.html rename {static => public}/previewFrame.html (100%) rename {static => public}/previewFramePaged.html (62%) delete mode 100644 src/AppRenderer.purs delete mode 100644 src/Data/Int/Parse.js delete mode 100644 src/Data/Int/Parse.purs delete mode 100644 src/Electron/CurrentWindow.js delete mode 100644 src/Electron/CurrentWindow.purs delete mode 100644 src/Electron/IpcRenderer.js delete mode 100644 src/Electron/IpcRenderer.purs delete mode 100644 src/Panwriter/App.purs delete mode 100644 src/Panwriter/Button.purs delete mode 100644 src/Panwriter/Document.js delete mode 100644 src/Panwriter/Document.purs delete mode 100644 src/Panwriter/File.js delete mode 100644 src/Panwriter/File.purs delete mode 100644 src/Panwriter/Formatter.purs delete mode 100644 src/Panwriter/MetaEditor.purs delete mode 100644 src/Panwriter/Preview.purs delete mode 100644 src/Panwriter/Toolbar.purs delete mode 100644 src/React/Basic/CodeMirror.js delete mode 100644 src/React/Basic/CodeMirror.purs delete mode 100644 src/React/Basic/ColorPicker.purs delete mode 100644 src/React/Basic/PreviewRenderer.js delete mode 100644 src/React/Basic/PreviewRenderer.purs delete mode 100644 src/React/Basic/ReactColor.js delete mode 100644 src/React/Basic/ReactColor.purs create mode 100644 src/appState/Action.ts create mode 100644 src/appState/AppState.ts create mode 100644 src/appState/appStateReducer.ts rename {static => src/assets}/preview.pandoc-styles.css (91%) rename static/app.css => src/components/App/App.css (77%) create mode 100644 src/components/App/App.tsx create mode 100644 src/components/Button.tsx rename static/app.colorpicker.css => src/components/ColorPicker/ColorPicker.css (100%) create mode 100644 src/components/ColorPicker/ColorPicker.tsx rename static/editor.css => src/components/Editor/Editor.css (96%) create mode 100644 src/components/Editor/Editor.tsx create mode 100644 src/components/Editor/codemirror.css rename static/app.metaeditor.css => src/components/MetaEditor/MetaEditor.css (100%) create mode 100644 src/components/MetaEditor/MetaEditor.tsx rename {static => src/components/MetaEditor}/back.svg (100%) create mode 100644 src/components/Preview/Preview.css create mode 100644 src/components/Preview/Preview.tsx rename static/app.toolbar.css => src/components/Toolbar/Toolbar.css (100%) create mode 100644 src/components/Toolbar/Toolbar.tsx rename {static => src/components/Toolbar}/macOS_window_close.svg (100%) rename {static => src/components/Toolbar}/macOS_window_maximize.svg (100%) rename {static => src/components/Toolbar}/macOS_window_minimize.svg (100%) rename {static => src/components/Toolbar}/metaEditor.svg (100%) rename {static => src/components/Toolbar}/notes.svg (100%) rename {static => src/components/Toolbar}/page.svg (100%) rename {static => src/components/Toolbar}/vertical_split.svg (100%) rename {static => src/components/Toolbar}/visibility.svg (100%) create mode 100644 src/index.tsx delete mode 100644 src/js/Document.js delete mode 100644 src/js/Exporter.js delete mode 100644 src/js/Importer.js delete mode 100644 src/js/Renderers.js delete mode 100644 src/js/renderer.js delete mode 100644 src/js/rendererPreload.js create mode 100644 src/raw-loader.d.ts create mode 100644 src/react-app-env.d.ts create mode 100644 src/renderPreview/convertMd.ts create mode 100644 src/renderPreview/convertYaml.ts create mode 100644 src/renderPreview/renderPreview.ts create mode 100644 src/renderPreview/renderPreviewImpl.ts create mode 100644 src/renderPreview/scrolling.ts create mode 100644 src/renderPreview/templates/getCss.ts rename src/{js/templates.js => renderPreview/templates/templates.ts} (52%) rename src/{js/throttle.js => renderPreview/throttle.ts} (65%) delete mode 100644 static/index.html delete mode 100644 static/preview.panwriter-default.css create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 3861649..ebee200 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ node_modules -output -.psci_modules -.psc-ide-port -.psc-package dist .DS_Store +.eslintcache +build +public/katex/ +public/paged.polyfill.js diff --git a/README.md b/README.md index fef0ee3..d47d545 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # PanWriter @@ -23,7 +23,7 @@ Select `File -> 'Print / PDF'` and `PDF -> 'Save as PDF'` in the print dialog (e This will export exactly what’s shown in the preview, and not use pandoc at all. -You can change the styling of the preview and immediately see the changes. (You can later save your CSS as a theme, see [Document types](#document-types--themes) below.) +You can change the styling of the preview and immediately see the changes. ![](screenshot-css.png) @@ -83,13 +83,11 @@ If the directory does not exist, you can create it. ### Default CSS and YAML -PanWriter will look for a `default.css` file in the user data directory, to load CSS for the preview. If that file is not found, it will use sensible defaults. - If you put a `default.yaml` file in the data directory, PanWriter will merge this with the YAML in your input file (to determine the command-line arguments to call pandoc with) and add the `--metadata-file` option. The YAML should be in the same format as above. ### Document types / themes -You can e.g. put `type: letter` in the YAML of your input document. In that case, PanWriter will look for `letter.yaml` and `letter.css` instead of `default.yaml` and `default.css` in the user data directory. +You can e.g. put `type: letter` in the YAML of your input document. In that case, PanWriter will look for `letter.yaml` instead of `default.yaml` in the user data directory. ### Markdown syntax diff --git a/electron/file.ts b/electron/file.ts new file mode 100644 index 0000000..89f5fc4 --- /dev/null +++ b/electron/file.ts @@ -0,0 +1,82 @@ +import { BrowserWindow, dialog } from 'electron' +import { readFile, writeFile } from 'fs' +import { basename, extname } from 'path' +import { promisify } from 'util' +import * as ipc from './ipc' +import { Doc } from '../src/appState/AppState' +import { addToRecentFiles } from './recentFiles' + + +export const openFile = async ( + win: BrowserWindow +, filePath: string +): Promise | undefined> => { + const fileName = pathToName(filePath) + + try { + const md = await promisify(readFile)(filePath, 'utf-8') + win.setTitle(fileName) + win.setRepresentedFilename(filePath) + addToRecentFiles(filePath) + return { md, fileName, filePath, fileDirty: false } + } catch (err) { + dialog.showMessageBox(win, { + type: 'error' + , message: 'Could not open file' + , detail: err.message + }) + win.close() + } +} + +export const saveFile = async ( + win: BrowserWindow +, doc: Doc +, opts: {saveAsNewFile?: boolean} = {} +) => { + const filePath = await showDialog(win, doc, opts.saveAsNewFile) + + if (!filePath) { + return + } + + try { + await promisify(writeFile)(filePath, doc.md) + + const fileName = pathToName(filePath) + win.setTitle(fileName) + win.setRepresentedFilename(filePath) + + ipc.sendMessage(win, { + type: 'updateDoc' + , doc: { fileName, filePath, fileDirty: false } + }) + + addToRecentFiles(filePath) + } catch (err) { + dialog.showMessageBox(win, { + type: 'error' + , message: 'Could not save file' + , detail: err.message + }) + } +} + +const showDialog = async (win: BrowserWindow, doc: Doc, saveAsNewFile?: boolean) => { + // TODO: should we save the filePath on `win` in the main process + // instead of risk it being tampered with in the renderer process? + let { filePath } = doc + if (filePath === undefined || saveAsNewFile) { + const res = await dialog.showSaveDialog(win, { + defaultPath: 'Untitled.md' + , filters: [ + { name: 'Markdown', extensions: ['md', 'txt', 'markdown'] } + ] + }) + filePath = res.filePath + } + return filePath +} + +const pathToName = (filePath: string) => + basename(filePath, extname(filePath)) diff --git a/electron/ipc.ts b/electron/ipc.ts new file mode 100644 index 0000000..85b9e71 --- /dev/null +++ b/electron/ipc.ts @@ -0,0 +1,54 @@ +import { BrowserWindow, ipcMain, shell } from 'electron' +import { Doc } from '../src/appState/AppState' +import { Message } from './preload' + +// this file contains the IPC functionality of the main process. +// for the renderer process's part see electron/preload.ts + +export const init = () => { + ipcMain.on('close', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + win?.close() + }) + + ipcMain.on('minimize', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + win?.minimize() + }) + + ipcMain.on('maximize', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + // win.isMaximized() ? win.unmaximize() : win.maximize() + win?.setFullScreen( !win.isFullScreen() ) + }) + + ipcMain.on('openLink', (_event, link: string) => { + shell.openExternal(link) + }) +} + +export const getDoc = async (win: BrowserWindow): Promise => { + const replyChannel = 'getDoc' + Math.random().toString() + win.webContents.send('getDoc', replyChannel) + return new Promise(resolve => { + ipcMain.once(replyChannel, (_event, doc) => { + resolve(doc) + }) + }) +} + +export const sendMessage = (win: BrowserWindow, msg: Message) => { + win.webContents.send('dispatch', msg) +} + +export const sendPlatform = (win: BrowserWindow) => { + win.webContents.send('sendPlatform', process.platform) +} + +export type Command = 'printFile' + | 'find' | 'findNext' | 'findPrevious' + | 'addBold' | 'addItalic' | 'addStrikethrough' + +export const sendCommand = (win: BrowserWindow, cmd: Command) => { + win.webContents.send(cmd) +} diff --git a/src/main.js b/electron/main.ts similarity index 50% rename from src/main.js rename to electron/main.ts index ae1a19a..7a35789 100644 --- a/src/main.js +++ b/electron/main.ts @@ -1,119 +1,170 @@ -"use strict"; +import { app, BrowserWindow, dialog, Menu } from 'electron' +import * as path from 'path' +import * as fs from 'fs' -// This file is currently the only one that runs in the main process -// see https://electronjs.org/docs/tutorial/application-architecture +import * as ipc from './ipc' +import { fileExportDialog, fileExportHTMLToClipboard, fileExportLikePrevious, fileExportToClipboard } from './pandoc/export' +import { Doc } from '../src/appState/AppState' +import { importFile } from './pandoc/import' +import { saveFile, openFile } from './file' +import { Message } from './preload' +import { clearRecentFiles, getRecentFiles } from './recentFiles' +const { autoUpdater } = require('electron-updater') +require('fix-path')() // needed to execute pandoc on macOS prod build -// Modules to control application life and create native browser window -const {app, dialog, BrowserWindow, Menu} = require('electron') - , {autoUpdater} = require("electron-updater") - , path = require('path') - , fs = require('fs') - ; +let appWillQuit = false + + +declare class CustomBrowserWindow extends Electron.BrowserWindow { + wasCreatedOnStartup?: boolean; + dontPreventClose?: boolean; +} // Keep a global reference of the windows, if you don't, the windows will // be closed automatically when the JavaScript object is garbage collected. -const windows = [] - , mdExtensions = ['md', 'txt', 'markdown'] - ; -let recentFiles = []; +const windows: CustomBrowserWindow[] = [] +const mdExtensions = ['md', 'txt', 'markdown'] -function createWindow(filePath, toImport=false, wasCreatedOnStartup=false) { - const win = new BrowserWindow({ +ipc.init() + +const createWindow = async (filePath?: string, toImport=false, wasCreatedOnStartup=false) => { + const win: CustomBrowserWindow = new BrowserWindow({ width: 1000 , height: 800 , frame: process.platform !== 'darwin' , show: false , webPreferences: { nodeIntegration: false - , contextIsolation: false - , preload: __dirname + '/js/rendererPreload.js' + , contextIsolation: true + , preload: __dirname + '/preload.js' + , sandbox: true } - }); - - win.wasCreatedOnStartup = wasCreatedOnStartup; - win.fileIsDirty = false; - win.filePathToLoad = filePath; - win.isFileToImport = toImport; - win.setTitle("Untitled"); - - windows.filter(w => w.wasCreatedOnStartup && !w.fileIsDirty).forEach(w => w.close()) - windows.push(win); - - win.once('ready-to-show', () => { - win.show(); - setMenu(); - }); + }) + + win.wasCreatedOnStartup = wasCreatedOnStartup + win.setTitle('Untitled') + + // close auto-created window when first user action is to open/import another file + windows.filter(w => w.wasCreatedOnStartup).forEach(async w => { + const { fileDirty } = await ipc.getDoc(w) + if (!fileDirty) { + w.close() + } + }) - win.loadFile('static/index.html') + windows.push(win) - // Open the DevTools. - // win.webContents.openDevTools() + const windowReady = new Promise(resolve => + win.once('ready-to-show', resolve) + ) - win.on('close', async function(e) { + const isDev = !!process.env.ELECTRON_IS_DEV + if (isDev) { + win.loadURL('http://localhost:3000/index.html') + } else { + // win.loadFile('build/index.html') + win.loadURL(`file://${__dirname}/../index.html`) + } + + if (isDev) { + win.webContents.openDevTools() + } + + if (filePath) { + const doc = toImport + ? await importFile(win, filePath) + : await openFile(win, filePath) + if (doc) { + await windowReady + ipc.sendMessage(win, { type: 'updateDoc', doc }) + } + } + await windowReady + ipc.sendPlatform(win) + win.show() + setMenu() + + win.on('close', async e => { // this does not intercept a reload // see https://github.com/electron/electron/blob/master/docs/api/browser-window.md#event-close // and https://github.com/electron/electron/issues/9966 - if (win.fileIsDirty) { - e.preventDefault(); - const selected = await dialog.showMessageBox(win, { - type: "question" - , message: "This document has unsaved changes." - , buttons: ["Save", "Cancel", "Don't Save"] - }) - switch (selected.response) { - case 0: - // Save - win.webContents.send('fileSave', {closeWindowAfterSave: true}); - break; - case 1: - // Cancel - break; - case 2: - // Don't Save - win.fileIsDirty = false; - win.close() - break; + if (!win.dontPreventClose) { + e.preventDefault() + const close = () => { + win.dontPreventClose = true + win.close() + if (appWillQuit) { + app.quit() + } + } + const doc = await ipc.getDoc(win) + if (doc.fileDirty) { + const selected = await dialog.showMessageBox(win, { + type: "question" + , message: "This document has unsaved changes." + , buttons: ["Save", "Cancel", "Don't Save"] + }) + switch (selected.response) { + case 0: { + // Save + win.dontPreventClose = true + await saveFile(win, doc) + close() + break + } + case 1: { + // Cancel + appWillQuit = false + break + } + case 2: { + // Don't Save + close() + break + } + } + } else { + close() } } - fetchRecentFiles(); // call to localStorage while we still have a window }) - win.on('closed', function() { + win.on('closed', () => { // Dereference the window so it can be garbage collected const i = windows.indexOf(win); if (i > -1) { windows.splice(i, 1); } - setMenuQuick(windows.length > 0); + setMenu(windows.length > 0, true); }) - win.on('minimize', function() { + win.on('minimize', () => { if (windows.filter(w => !w.isMinimized()).length === 0) { // no non-minimized windows - setMenu(false); + setMenu(false, true); } }); - win.on('restore', function() { - setMenu(); + win.on('restore', () => { + setMenu(true, false); }); } // macOS only, on file-drag etc. // see https://electronjs.org/docs/all#event-open-file-macos // and https://www.electron.build/configuration/configuration#PlatformSpecificBuildOptions-fileAssociations -app.on('open-file', function(e, filePath) { +app.on('open-file', (e, filePath) => { e.preventDefault(); const toImport = mdExtensions.indexOf( path.extname(filePath).substr(1) ) < 0; app.whenReady().then(() => createWindow(filePath, toImport)); -}); +}) // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. -app.on('ready', function() { +app.on('ready', () => { const args = process.argv.slice(1) if (args.length > 0 && app.isPackaged) { args.forEach(arg => { @@ -125,23 +176,26 @@ app.on('ready', function() { }); } else if (windows.length === 0) { createWindow(undefined, false, true); - setMenuQuick(); } autoUpdater.checkForUpdatesAndNotify(); }) +app.on('before-quit', e => { + appWillQuit = true +}); + // Quit when all windows are closed. -app.on('window-all-closed', function() { +app.on('window-all-closed', () => { // On OS X it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q if (process.platform === 'darwin') { - setMenuQuick(false); + setMenu(false, false); } else { app.quit() } }) -app.on('activate', function() { +app.on('activate', () => { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (windows.length === 0) { @@ -149,7 +203,7 @@ app.on('activate', function() { } }) -async function openDialog(toImport=false) { +const openDialog = async (toImport=false) => { const formats = toImport ? [] : [ { name: 'Markdown', extensions: mdExtensions } ] @@ -163,17 +217,33 @@ async function openDialog(toImport=false) { } } -function windowSend(name, opts) { - const win = BrowserWindow.getFocusedWindow(); - win.webContents.send(name, opts); +const invokeWithWinAndDoc = async (fn: (win: BrowserWindow, doc: Doc) => void) => { + const win = BrowserWindow.getFocusedWindow() + if (win) { + const doc = await ipc.getDoc(win) + fn(win, doc) + } else { + throw Error('no window was focused') + } +} + +const windowSendCommand = async (cmd: ipc.Command) => { + const win = BrowserWindow.getFocusedWindow() + if (win) { + ipc.sendCommand(win, cmd) + } } -function setMenu(aWindowIsOpen=true) { - fetchRecentFiles().then( () => setMenuQuick(aWindowIsOpen) ); +const windowSendMessage = async (msg: Message) => { + const win = BrowserWindow.getFocusedWindow() + if (win) { + ipc.sendMessage(win, msg) + } } -function setMenuQuick(aWindowIsOpen=true) { - var template = [ +const setMenu = async (aWindowIsOpen=true, useRecentFilesCache=false) => { + const recentFiles = await getRecentFiles(useRecentFilesCache) + const template: Electron.MenuItemConstructorOptions[] = [ { label: 'File' , submenu: [ { label: 'New' @@ -189,11 +259,14 @@ function setMenuQuick(aWindowIsOpen=true) { return { label: path.basename(f) , click: () => createWindow(f) - } + } as Electron.MenuItemConstructorOptions }).concat([ {type: 'separator'} , { label: 'Clear Menu' - , click: clearRecentFiles + , click: () => { + clearRecentFiles() + setMenu(true, true) + } , enabled: recentFiles.length > 0 && aWindowIsOpen } ]) @@ -201,37 +274,37 @@ function setMenuQuick(aWindowIsOpen=true) { , {type: 'separator'} , { label: 'Save' , accelerator: 'CmdOrCtrl+S' - , click: () => windowSend('fileSave') + , click: () => invokeWithWinAndDoc((win, doc) => saveFile(win, doc)) , enabled: aWindowIsOpen } , { label: 'Save As…' , accelerator: 'CmdOrCtrl+Shift+S' - , click: () => windowSend('fileSave', {saveAsNewFile: true}) + , click: () => invokeWithWinAndDoc((win, doc) => saveFile(win, doc, { saveAsNewFile: true })) , enabled: aWindowIsOpen } , { label: 'Print / PDF' , accelerator: 'CmdOrCtrl+P' - , click: () => windowSend('filePrint') + , click: () => windowSendCommand('printFile') , enabled: aWindowIsOpen } , { label: 'Export…' , accelerator: 'CmdOrCtrl+Shift+E' - , click: () => windowSend('fileExport') + , click: () => invokeWithWinAndDoc(fileExportDialog) , enabled: aWindowIsOpen } , { label: 'Export like previous' , accelerator: 'CmdOrCtrl+E' - , click: () => windowSend('fileExportLikePrevious') + , click: () => invokeWithWinAndDoc(fileExportLikePrevious) , enabled: aWindowIsOpen } , { label: 'Export to clipboard' , accelerator: 'CmdOrCtrl+Alt+E' - , click: () => windowSend('fileExportToClipboard') + , click: () => invokeWithWinAndDoc(fileExportToClipboard) , enabled: aWindowIsOpen } , { label: 'Export as rich text to clipboard' , accelerator: 'CmdOrCtrl+Alt+Shift+E' - , click: () => windowSend('fileExportHTMLToClipboard') + , click: () => invokeWithWinAndDoc(fileExportHTMLToClipboard) , enabled: aWindowIsOpen } , { label: 'Import…' @@ -249,21 +322,21 @@ function setMenuQuick(aWindowIsOpen=true) { , {role: 'copy'} , {role: 'paste'} , {role: 'delete'} - , {role: 'selectall'} + , {role: 'selectall' as Electron.MenuItemConstructorOptions['role']} , {type: 'separator'} , { label: 'Find' , accelerator: 'CmdOrCtrl+F' - , click: () => windowSend('find') + , click: () => windowSendCommand('find') , enabled: aWindowIsOpen } , { label: 'Find Next' , accelerator: 'CmdOrCtrl+G' - , click: () => windowSend('findNext') + , click: () => windowSendCommand('findNext') , enabled: aWindowIsOpen } , { label: 'Find Previous' , accelerator: 'CmdOrCtrl+Shift+G' - , click: () => windowSend('findPrevious') + , click: () => windowSendCommand('findPrevious') , enabled: aWindowIsOpen } ] @@ -272,16 +345,16 @@ function setMenuQuick(aWindowIsOpen=true) { , submenu: [ { label: 'Bold' , accelerator: 'CmdOrCtrl+B' - , click: () => windowSend('addBold') + , click: () => windowSendCommand('addBold') , enabled: aWindowIsOpen } , { label: 'Italic' , accelerator: 'CmdOrCtrl+I' - , click: () => windowSend('addItalic') + , click: () => windowSendCommand('addItalic') , enabled: aWindowIsOpen } , { label: 'Strikethrough' - , click: () => windowSend('addStrikethrough') + , click: () => windowSendCommand('addStrikethrough') , enabled: aWindowIsOpen } ] @@ -290,25 +363,25 @@ function setMenuQuick(aWindowIsOpen=true) { , submenu: [ { label: 'Show Only Editor' , accelerator: 'CmdOrCtrl+1' - , click: () => windowSend('splitViewOnlyEditor') + , click: () => windowSendMessage({ type: 'split', split: 'onlyEditor' }) , enabled: aWindowIsOpen } , { label: 'Show Split View' , accelerator: 'CmdOrCtrl+2' - , click: () => windowSend('splitViewSplit') + , click: () => windowSendMessage({ type: 'split', split: 'split' }) , enabled: aWindowIsOpen } , { label: 'Show Only Preview' , accelerator: 'CmdOrCtrl+3' - , click: () => windowSend('splitViewOnlyPreview') + , click: () => windowSendMessage({ type: 'split', split: 'onlyPreview' }) , enabled: aWindowIsOpen } , {type: 'separator'} - , {role: 'toggledevtools'} + , {role: 'toggledevtools' as Electron.MenuItemConstructorOptions['role']} , {type: 'separator'} - , {role: 'resetzoom'} - , {role: 'zoomin'} - , {role: 'zoomout'} + , {role: 'resetzoom' as Electron.MenuItemConstructorOptions['role']} + , {role: 'zoomin' as Electron.MenuItemConstructorOptions['role']} + , {role: 'zoomout' as Electron.MenuItemConstructorOptions['role']} , {type: 'separator'} , {role: 'togglefullscreen'} ] @@ -323,8 +396,10 @@ function setMenuQuick(aWindowIsOpen=true) { if (!app.isPackaged) { const viewMenu = template[3].submenu; - viewMenu.push({type: 'separator'}); - viewMenu.push({role: 'forcereload'}); + if (viewMenu && ('push' in viewMenu)) { + viewMenu.push({type: 'separator'}); + viewMenu.push({role: 'forcereload' as Electron.MenuItemConstructorOptions['role']}); + } } if (process.platform === 'darwin') { @@ -336,7 +411,7 @@ function setMenuQuick(aWindowIsOpen=true) { , {role: 'services', submenu: []} , {type: 'separator'} , {role: 'hide'} - , {role: 'hideothers'} + , {role: 'hideothers' as Electron.MenuItemConstructorOptions['role']} , {role: 'unhide'} , {type: 'separator'} , {role: 'quit'} @@ -355,21 +430,3 @@ function setMenuQuick(aWindowIsOpen=true) { var menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); } - -// fetches recentFiles from the localStorage of a renderer process -async function fetchRecentFiles() { - const win = BrowserWindow.getFocusedWindow(); - if (win) { - return win.webContents.executeJavaScript("localStorage.getItem('recentFiles')") - .then(res => { - recentFiles = JSON.parse(res) || [] - }); - } -} - -function clearRecentFiles() { - const win = BrowserWindow.getFocusedWindow(); - win.webContents.executeJavaScript("localStorage.setItem('recentFiles', '[]')") - recentFiles = [] - setMenuQuick(); -} diff --git a/electron/pandoc/export.ts b/electron/pandoc/export.ts new file mode 100644 index 0000000..f055db7 --- /dev/null +++ b/electron/pandoc/export.ts @@ -0,0 +1,311 @@ +import { spawn, SpawnOptionsWithoutStdio } from 'child_process' +import { app, BrowserWindow, clipboard, dialog } from 'electron' +import { readFile } from 'fs' +import * as jsYaml from 'js-yaml' +import { basename, dirname, extname, sep } from 'path' +import { promisify } from 'util' +import { Doc, JSON, Meta } from '../../src/appState/AppState' + +interface ExportOptions { + outputPath?: string; + spawnOpts?: SpawnOptionsWithoutStdio; + toClipboardFormat?: string; + toClipboardHTML?: boolean; +} + +interface Out { + metadata?: Meta; + output?: string; + to?: string; + standalone?: boolean; + [key: string]: undefined | JSON; +} + +declare class CustomBrowserWindow extends Electron.BrowserWindow { + previousExportConfig?: ExportOptions; +} + +export const dataDir = [app.getPath('appData'), 'PanWriterUserData', ''].join(sep) + +export const fileExportDialog = async (win: CustomBrowserWindow, doc: Doc) => { + const spawnOpts: SpawnOptionsWithoutStdio = {} + const inputPath = doc.filePath + + var defaultPath; + if (inputPath !== undefined) { + spawnOpts.cwd = dirname(inputPath); + defaultPath = basename(inputPath, extname(inputPath)) + } + + const res = await dialog.showSaveDialog(win, { + defaultPath: defaultPath + , buttonLabel: 'Export' + , filters: exportFormats + }) + + const outputPath = res.filePath + if (outputPath){ + const exp = { + outputPath + , spawnOpts + }; + await fileExport(win, doc, exp) + win.previousExportConfig = exp + } +} + +export const fileExportLikePrevious = (win: CustomBrowserWindow, doc: Doc) => { + if (win.previousExportConfig) { + fileExport(win, doc, win.previousExportConfig) + } else { + fileExportDialog(win, doc) + } +} + +export const fileExportToClipboard = (win: BrowserWindow, doc: Doc) => { + const { meta } = doc + const format = meta.output && Object.keys(meta.output)[0] + if (format) { + fileExport(win, doc, {toClipboardFormat: format}) + } else { + dialog.showMessageBox(win, { + type: 'error' + , message: 'Couldn\'t find output format in YAML metadata' + , detail: `Add something like the following at the top of your document: + +--- +output: + html: true +---` + }) + } +} + +export const fileExportHTMLToClipboard = (win: BrowserWindow, doc: Doc) => { + fileExport(win, doc, { toClipboardFormat: 'html', toClipboardHTML: true }) +} + + +/** + * Calls pandoc, takes export settings object + */ +const fileExport = async (win: BrowserWindow, doc: Doc, exp: ExportOptions) => { + // simplified version of what I did in https://github.com/mb21/panrun + const docMeta = doc.meta + const type = typeof docMeta.type === 'string' + ? docMeta.type + : 'default' + const [extMeta, fileArg] = await defaultMeta(type) + const out = mergeAndValidate(docMeta, extMeta, exp.outputPath, exp.toClipboardFormat) + + const cmd = 'pandoc' + const args = fileArg.concat( toArgs(out) ) + const cmdDebug = cmd + ' ' + args.join(' ') + let receivedError = false + + try { + const pandoc = spawn(cmd, args, exp.spawnOpts); + pandoc.stdin.write(doc.md); + pandoc.stdin.end(); + + pandoc.on('error', err => { + receivedError = true + dialog.showMessageBox(win, { + type: 'error' + , message: 'Failed to call pandoc' + , detail: `Make sure you have it installed, see pandoc.org/installing + + Failed to execute command: + ${cmdDebug} + + ${err.message}` + }) + }); + + const errout: string[] = []; + pandoc.stderr.on('data', data => { + errout.push(data.toString('utf8')); + }); + + const stdout: string[] = []; + if (exp.toClipboardFormat) { + pandoc.stdout.on('data', data => { + stdout.push(data.toString('utf8')); + }); + } + + pandoc.on('close', exitCode => { + const success = exitCode === 0 + const toMsg = 'Called: ' + cmdDebug + if (success && exp.toClipboardFormat) { + if (exp.toClipboardHTML) { + clipboard.write({ + text: doc.md, + html: stdout.join('') + }); + } else { + clipboard.writeText(stdout.join('')); + } + } + if ((!exp.toClipboardFormat || !success) && !receivedError) { + dialog.showMessageBox(win, { + type: success ? 'info' : 'error' + , message: success ? 'Success!' : 'Failed to export' + , detail: [toMsg, ''].concat( errout.join('') ).join('\n') + , buttons: ['OK'] + }); + } + }); + } catch (e) { + console.error('Failed to spawn pandoc', e) + } +}; + +/** + * merges both metas, sets proper defaults and returns output[toFormat] part + */ +const mergeAndValidate = (docMeta: Meta, extMeta: Meta, outputPath?: string, toClipboardFormat?: string): Out => { + let toFormat: string + if (outputPath) { + toFormat = extname(outputPath) + if (toFormat && toFormat[0] === '.') { + toFormat = toFormat.substr(1); + } + if (toFormat === 'pdf') { + const fmt = docMeta['pdf-format'] || extMeta['pdf-format'] || 'latex'; + if (typeof fmt === 'string') { + toFormat = fmt + } + } else if (toFormat === 'tex') { + toFormat = 'latex'; + } + } else if (toClipboardFormat) { + toFormat = toClipboardFormat; + } else { + return {} + } + + const jsonToObj = (m: JSON): Meta => + (m && typeof m === 'object' && !Array.isArray(m)) + ? m + : {} + + const extractOut = (meta: Meta) => + (meta?.output && typeof meta.output === 'object' && !Array.isArray(meta.output)) + ? jsonToObj(meta.output[toFormat]) + : {} + const out: Out = { ...extractOut(extMeta), ...extractOut(docMeta) } + + if (typeof out.metadata !== 'object') { + out.metadata = {}; + } + if (docMeta.mainfont === undefined) { + out.metadata.mainfont = '-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif'; + } + if (docMeta.monobackgroundcolor === undefined) { + out.metadata.monobackgroundcolor = '#f0f0f0'; + } + + if (outputPath) { + //make sure output goes to file user selected in GUI + out.output = outputPath; + } + + // allow user to set `to: epub2`, `to: gfm`, `to: revealjs` etc. + if (out.to === undefined) { + out.to = toFormat; + } + + // unless explicitly disabled, use `-s` + if (out.standalone !== false && !toClipboardFormat) { + out.standalone = true; + } + + return out; +} + +/** + * reads the right default yaml file + */ +const defaultMeta = async (type: string): Promise<[Meta, string[]]> => { + try { + const [str, fileName] = await readDataDirFile(type, '.yaml'); + const yaml = jsYaml.safeLoad(str) + return [ typeof yaml === 'object' ? (yaml as Meta) : {}, ['--metadata-file', fileName] ] + } catch(e) { + console.warn("Error loading or parsing YAML file." + e.message); + return [ {}, [] ]; + } +} + +// reads file from data directory, throws exception when not found +const readDataDirFile = async (type: string, suffix: string) => { + const fileName = dataDir + type + suffix + const str = await promisify(readFile)(fileName, 'utf8') + return [str, fileName] +} + +// constructs commandline arguments from object +const toArgs = (out: Out) => { + const args: string[] = []; + + Object.keys(out).forEach(opt => { + const val = out[opt]; + if ( Array.isArray(val) ) { + val.forEach(v => { + if (typeof v === 'string') { + args.push('--' + opt); + args.push(v); + } + }); + } else if (val && typeof val === 'object') { + Object.keys(val).forEach(k => { + args.push('--' + opt); + args.push(k + '=' + val[k]); + }); + } else if (val !== false) { + args.push('--' + opt); + if (val && val !== true) { + // pandoc boolean options don't take a value + args.push( val.toString() ); + } + } + }); + + return args; +} + +// we rely on the extension to detect target format +// see https://github.com/electron/electron/issues/15254 +// list based on https://github.com/jgm/pandoc/blob/master/README.md +const exportFormats = [ + { name: 'HTML (html)', extensions: ['html'] } +, { name: 'Word (docx)', extensions: ['docx'] } +, { name: 'LaTeX (latex)', extensions: ['tex'] } +, { name: 'PDF (latex | context | html | ms)', extensions: ['pdf'] } +, { name: 'ConTeXt (context)', extensions: ['context'] } +, { name: 'InDesign ICML (icml)', extensions: ['icml'] } +, { name: 'PowerPoint (pptx)', extensions: ['pptx'] } +, { name: 'OpenOffice/LibreOffice (odt)', extensions: ['odt'] } +, { name: 'RTF (rtf)', extensions: ['rtf'] } +, { name: 'EPUB (epub)', extensions: ['epub'] } +, { name: 'DocBook XML (docbook)', extensions: ['docbook'] } +, { name: 'JATS XML (jats)', extensions: ['jats'] } +, { name: 'Text Encoding Initiative (tei)', extensions: ['tei'] } +, { name: 'OPML (opml)', extensions: ['opml'] } +, { name: 'FictionBook2 (fb2)', extensions: ['fb2'] } +, { name: 'groff (ms)', extensions: ['ms'] } +, { name: 'GNU Texinfo (texinfo)', extensions: ['texinfo'] } +, { name: 'Textile (textile)', extensions: ['textile'] } +, { name: 'Jira wiki', extensions: ['jira'] } +, { name: 'DokuWiki (dokuwiki)', extensions: ['dokuwiki'] } +, { name: 'MediaWiki (mediawiki)', extensions: ['mediawiki'] } +, { name: 'Muse (muse)', extensions: ['muse'] } +, { name: 'ZimWiki (zimwiki)', extensions: ['zimwiki'] } +, { name: 'AsciiDoc (asciidoc)', extensions: ['asciidoc'] } +, { name: 'Emacs Org mode (org)', extensions: ['org'] } +, { name: 'reStructuredText (rst)', extensions: ['rst'] } +, { name: 'Markdown (md)', extensions: ['md'] } +, { name: 'Plain text (txt)', extensions: ['txt'] } +, { name: 'Other format', extensions: ['*'] } +] diff --git a/electron/pandoc/import.ts b/electron/pandoc/import.ts new file mode 100644 index 0000000..f5a2afc --- /dev/null +++ b/electron/pandoc/import.ts @@ -0,0 +1,66 @@ +// TODO: GUI popup for import options, at least for: +// -f, -t, --track-changes and --extract-media + +import { spawn } from 'child_process' +import { BrowserWindow, dialog } from 'electron' +import { dirname } from 'path' +import { Doc } from '../../src/appState/AppState' + +export const importFile = async ( + win: BrowserWindow +, inputPath: string +) => { + const cmd = 'pandoc' + const args = [ inputPath, '--wrap=none' + , '-t', 'markdown-raw_html-raw_tex-header_attributes-fancy_lists-simple_tables-multiline_tables' + ] + const cwd = dirname(inputPath) + const cmdDebug = cmd + ' ' + args.join(' ') + return new Promise>((resolve, reject) => { + + const pandoc = spawn(cmd, args, {cwd}) + + pandoc.on('error', err => { + dialog.showMessageBox(win, { + type: 'error' + , message: 'Failed to call pandoc' + , detail: `Make sure you have it installed, see pandoc.org/installing + +Failed to execute command: +${cmdDebug} + +${err.message}` + }) + }); + + const stdout: string[] = [] + pandoc.stdout.on('data', data => { + stdout.push(data) + }) + + const errout: string[] = [] + pandoc.stderr.on('data', data => { + errout.push(data) + }) + + pandoc.on('close', exitCode => { + const success = exitCode === 0 + const toMsg = "Called: " + cmdDebug + if (success) { + resolve({ + md: stdout.join('') + , fileDirty: true + }) + } else { + dialog.showMessageBox(win, { + type: 'error' + , message: 'Failed to import' + , detail: [toMsg, ''].concat( errout.join('') ).join('\n') + , buttons: ['OK'] + }) + win.close() + // reject('failed to import') + } + }) + }) +} diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 0000000..752fe30 --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,59 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { AppState, Doc, ViewSplit } from '../src/appState/AppState' +import { Action } from '../src/appState/Action' + +export type IpcApi = typeof ipcApi +type Disp = (a: Action) => void + +let state: AppState | undefined = undefined +let dispatch: Disp | undefined = undefined + +ipcRenderer.on('getDoc', (_e, replyChannel: string) => { + if (state) { + ipcRenderer.send(replyChannel, state.doc) + } +}) + +export type Message = { + type: 'updateDoc'; + doc: Partial; +} +| { + type: 'split'; + split: ViewSplit; +} + +ipcRenderer.on('dispatch', (_e, action: Message) => { + if (dispatch) { + if (action.type === 'split') { + dispatch({ type: 'setSplitAndRender', split: action.split }) + } else { + dispatch(action) + } + } +}) + +const ipcApi = { + setStateAndDispatch: (s: AppState, d: Disp) => { + state = s + dispatch = d + } +, send: { + close: () => ipcRenderer.send('close') + , minimize: () => ipcRenderer.send('minimize') + , maximize: () => ipcRenderer.send('maximize') + , openLink: (link: string) => ipcRenderer.send('openLink', link) + } +, on: { + addBold: (cb: () => void) => ipcRenderer.on('addBold', cb) + , addItalic: (cb: () => void) => ipcRenderer.on('addItalic', cb) + , addStrikethrough: (cb: () => void) => ipcRenderer.on('addStrikethrough', cb) + , find: (cb: () => void) => ipcRenderer.on('find', cb) + , findNext: (cb: () => void) => ipcRenderer.on('findNext', cb) + , findPrevious: (cb: () => void) => ipcRenderer.on('findPrevious', cb) + , printFile: (cb: () => void) => ipcRenderer.on('printFile', cb) + , sendPlatform: (cb: (p: string) => void) => ipcRenderer.once('sendPlatform', (_e, p) => cb(p)) + } +} + +contextBridge.exposeInMainWorld('ipcApi', ipcApi) diff --git a/electron/recentFiles.ts b/electron/recentFiles.ts new file mode 100644 index 0000000..3d38599 --- /dev/null +++ b/electron/recentFiles.ts @@ -0,0 +1,44 @@ +import { app } from 'electron' +import * as fs from 'fs' +import { sep } from 'path' +import { promisify } from 'util' + +// since on Windows and Linux multiple instances of our app can be running concurrently, +// we usually have to read out the file again every time +let recentFilesCache: string[] = [] + +const storageFileName = app.getPath('userData') + sep + 'recentFiles.json' + +export const getRecentFiles = async (useCache = false): Promise => { + if (useCache) { + return recentFilesCache + } + + let recents = [] + try { + const contents = await promisify(fs.readFile)(storageFileName, 'utf8') + recents = JSON.parse(contents) || [] + } catch (e) { + console.info('no recentFiles found?', e) + } + recentFilesCache = recents + return recents +} + +export const addToRecentFiles = async (filePath: string): Promise => { + let recents = await getRecentFiles() + recents = recents.filter(f => f !== filePath) + recents.unshift(filePath) + recents = recents.slice(0, 15) + recentFilesCache = recents + try { + return promisify(fs.writeFile)(storageFileName, JSON.stringify(recents)) + } catch (e) { + console.warn('could not addToRecentFiles', e) + } +} + +export const clearRecentFiles = () => { + recentFilesCache = [] + promisify(fs.writeFile)(storageFileName, '[]') +} diff --git a/electron/tsconfig.json b/electron/tsconfig.json new file mode 100644 index 0000000..d97a2c3 --- /dev/null +++ b/electron/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "sourceMap": true, + "strict": true, + "outDir": "../build", + "rootDir": "../", + "noEmitOnError": true, + "typeRoots": [ + "node_modules/@types" + ] + } +} diff --git a/build/icons/icon-COPYING.txt b/icons/icon-COPYING.txt similarity index 100% rename from build/icons/icon-COPYING.txt rename to icons/icon-COPYING.txt diff --git a/build/icons/icon.ico b/icons/icon.ico similarity index 100% rename from build/icons/icon.ico rename to icons/icon.ico diff --git a/build/icons/icon.png b/icons/icon.png similarity index 100% rename from build/icons/icon.png rename to icons/icon.png diff --git a/package.json b/package.json index 8b49639..7eb4276 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,21 @@ "productName": "PanWriter", "version": "0.7.2", "description": "Markdown editor with pandoc integration and paginated preview", - "main": "src/main.js", + "homepage": ".", + "main": "build/electron/main.js", "scripts": { - "start": "electron .", - "install": "psc-package install", - "build": "psc-package build", - "clean": "rm -rf .cache .psc-package .psci_modules node_modules output dist", - "debug": "electron --inspect-brk=5858 .", - "dist": "electron-builder", + "start": "react-scripts start", + "build": "INLINE_RUNTIME_CHUNK=false react-scripts build", + "postinstall": "cp node_modules/pagedjs/dist/paged.polyfill.js public && cp -r node_modules/katex/dist/ public/katex && cp node_modules/markdown-it-texmath/css/texmath.css public/katex", + "test": "react-scripts test", + "eject": "react-scripts eject", + "lint": "eslint src", + "tsc": "tsc", + "electron": "electron .", + "electron:tsc": "tsc -p electron", + "electron:dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && yarn run electron:tsc -w\" \"wait-on http://localhost:3000 && yarn run electron:tsc -p electron && ELECTRON_IS_DEV=1 electron .\"", + "electron:build": "yarn build && yarn run electron:tsc", + "dist": "yarn run electron:build && electron-builder", "dist-all": "electron-builder -mlw", "release": "electron-builder -mlw --publish always" }, @@ -22,7 +29,29 @@ "author": "Mauro Bieg", "License": "GPL-3.0-or-later", "publish": "github", + "private": true, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + "chrome 89" + ], + "development": [ + "chrome 89" + ] + }, "build": { + "extends": null, + "files": [ + "build/**/*" + ], + "directories": { + "buildResources": "assets" + }, "appId": "com.panwriter.app", "fileAssociations": [ { @@ -48,16 +77,17 @@ } ], "mac": { - "icon": "build/icons/icon.png" + "icon": "icons/icon.png" }, "win": { - "icon": "build/icons/icon.ico" + "icon": "icons/icon.ico" }, "nsis": { "allowToChangeInstallationDirectory": true, "oneClick": false }, "linux": { + "icon": "icons/icon.png", "desktop": { "Name": "PanWriter", "Comment": "Markdown editor with pandoc integration and paginated preview" @@ -70,11 +100,11 @@ } }, "dependencies": { - "codemirror": "^5.42.2", - "electron-updater": "^4.3.1", - "fix-path": "^2.1.0", + "codemirror": "^5.59.1", + "electron-updater": "^4.3.5", + "fix-path": "^3.0.0", "js-yaml": "^3.14.0", - "katex": "^0.10.0-rc.1", + "katex": "^0.13.0", "markdown-it": "^12.0.2", "markdown-it-attrs": "^3.0.3", "markdown-it-bracketed-spans": "^1.0.1", @@ -86,17 +116,26 @@ "markdown-it-pandoc": "^1.1.0", "markdown-it-sub": "^1.0.0", "markdown-it-sup": "^1.0.0", - "markdown-it-texmath": "^0.5.2", + "markdown-it-texmath": "^0.8.0", "pagedjs": "0.1.43", - "react": "^16.6.1", - "react-codemirror2": "^5.1.0", + "path-dirname": "^1.0.2", + "react": "^17.0.1", + "react-codemirror2": "^7.2.1", "react-color": "^2.19.3", - "react-dom": "^16.6.1" + "react-dom": "^17.0.1", + "react-scripts": "4.0.1" }, "devDependencies": { - "electron": "9.3.1", - "electron-builder": "^22.7.0", - "psc-package": "^3.0.1", - "purescript": "^0.12.1" + "@types/codemirror": "^0.0.102", + "@types/js-yaml": "^3.12.5", + "@types/react": "^16.9.53", + "@types/react-color": "^3.0.4", + "@types/react-dom": "^16.9.8", + "concurrently": "^5.3.0", + "electron": "^12.0.2", + "electron-builder": "^22.10.5", + "raw-loader": "^4.0.2", + "typescript": "^4.1.3", + "wait-on": "^5.2.0" } } diff --git a/psc-package.json b/psc-package.json deleted file mode 100644 index 787e8f1..0000000 --- a/psc-package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "panwriter", - "set": "psc-0.12.1", - "source": "https://github.com/purescript/package-sets.git", - "depends": [ - "argonaut-core", - "console", - "effect", - "psci-support", - "react-basic", - "strings", - "web-html" - ] -} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b82d7ac --- /dev/null +++ b/public/index.html @@ -0,0 +1,15 @@ + + + + + + + + +
+ + diff --git a/static/previewFrame.html b/public/previewFrame.html similarity index 100% rename from static/previewFrame.html rename to public/previewFrame.html diff --git a/static/previewFramePaged.html b/public/previewFramePaged.html similarity index 62% rename from static/previewFramePaged.html rename to public/previewFramePaged.html index eb6f010..9822a29 100644 --- a/static/previewFramePaged.html +++ b/public/previewFramePaged.html @@ -2,11 +2,7 @@ - - + diff --git a/src/AppRenderer.purs b/src/AppRenderer.purs deleted file mode 100644 index 4f05c0e..0000000 --- a/src/AppRenderer.purs +++ /dev/null @@ -1,20 +0,0 @@ -module AppRenderer where - -import Prelude -import Panwriter.App (app) - -import Data.Maybe (Maybe(..)) -import Effect (Effect) -import Effect.Exception (throw) -import React.Basic.DOM (render) -import Web.DOM.NonElementParentNode (getElementById) -import Web.HTML (window) -import Web.HTML.HTMLDocument (toNonElementParentNode) -import Web.HTML.Window (document) - -main :: Effect Unit -main = do - mc <- getElementById "container" =<< (map toNonElementParentNode $ document =<< window) - case mc of - Nothing -> throw "Container element not found." - Just c -> render (app {}) c diff --git a/src/Data/Int/Parse.js b/src/Data/Int/Parse.js deleted file mode 100644 index bb5588a..0000000 --- a/src/Data/Int/Parse.js +++ /dev/null @@ -1,6 +0,0 @@ -"use strict"; -// from https://github.com/athanclark/purescript-parseint/tree/master/src/Data/Int - -exports.unsafeParseInt = function unsafeParseInt(input) { - return parseInt(input, 10); -}; diff --git a/src/Data/Int/Parse.purs b/src/Data/Int/Parse.purs deleted file mode 100644 index 4dcfde8..0000000 --- a/src/Data/Int/Parse.purs +++ /dev/null @@ -1,21 +0,0 @@ ---from https://github.com/athanclark/purescript-parseint/tree/master/src/Data/Int - -module Data.Int.Parse (parseInt) where - -import Data.Maybe (Maybe (..)) -import Data.Int (round) -import Data.Function.Uncurried (Fn1, runFn1) -import Global (isNaN) - - -foreign import unsafeParseInt :: Fn1 String Number - --- | Warning - this function follows the same semantics as native JS's `parseInt()` function - --- | it will parse "as much as it can", when it can - sometimes it succeeds when the input isn't --- | completely sanitary. -parseInt :: String -> Maybe Int -parseInt s = - let x = runFn1 unsafeParseInt s - in if isNaN x - then Nothing - else Just (round x) diff --git a/src/Electron/CurrentWindow.js b/src/Electron/CurrentWindow.js deleted file mode 100644 index ec9a0df..0000000 --- a/src/Electron/CurrentWindow.js +++ /dev/null @@ -1,19 +0,0 @@ -"use strict"; - -var remote = require('electron').remote; - -exports.close = function() { - var win = remote.getCurrentWindow(); - win.close(); -} - -exports.minimize = function() { - var win = remote.getCurrentWindow(); - win.minimize(); -} - -exports.maximize = function() { - var win = remote.getCurrentWindow(); - //win.isMaximized() ? win.unmaximize() : win.maximize(); - win.setFullScreen( !win.isFullScreen() ) -} diff --git a/src/Electron/CurrentWindow.purs b/src/Electron/CurrentWindow.purs deleted file mode 100644 index d80289d..0000000 --- a/src/Electron/CurrentWindow.purs +++ /dev/null @@ -1,8 +0,0 @@ -module Electron.CurrentWindow where - -import Prelude (Unit) -import Effect (Effect) - -foreign import close :: Effect Unit -foreign import minimize :: Effect Unit -foreign import maximize :: Effect Unit diff --git a/src/Electron/IpcRenderer.js b/src/Electron/IpcRenderer.js deleted file mode 100644 index 2203f9b..0000000 --- a/src/Electron/IpcRenderer.js +++ /dev/null @@ -1,13 +0,0 @@ -"use strict"; - -var ipcRenderer = require('electron').ipcRenderer - -exports.on = function(channel) { - return function(listener) { - return function() { - ipcRenderer.on(channel, function(){ - listener(); - }); - }; - }; -}; diff --git a/src/Electron/IpcRenderer.purs b/src/Electron/IpcRenderer.purs deleted file mode 100644 index d056faa..0000000 --- a/src/Electron/IpcRenderer.purs +++ /dev/null @@ -1,8 +0,0 @@ -module Electron.IpcRenderer where - -import Prelude -import Effect (Effect) - -foreign import on :: String -- ^ Channel name - -> Effect Unit -- ^ Listener callback - -> Effect Unit diff --git a/src/Panwriter/App.purs b/src/Panwriter/App.purs deleted file mode 100644 index a2903b5..0000000 --- a/src/Panwriter/App.purs +++ /dev/null @@ -1,154 +0,0 @@ -module Panwriter.App where - -import Prelude -import Data.Monoid (guard) -import Effect (Effect) - -import Electron.IpcRenderer as Ipc -import Panwriter.Document (updateDocument) -import Panwriter.File (initFile, setWindowDirty) -import Panwriter.Formatter as Formatter -import Panwriter.MetaEditor (metaEditor) -import Panwriter.Preview (preview) -import Panwriter.Toolbar (ViewSplit(..), toolbar) -import React.Basic (Component, JSX, StateUpdate(..), capture_, createComponent, make, send) -import React.Basic.CodeMirror as CodeMirror -import React.Basic.DOM as R -import React.Basic.PreviewRenderer (renderMd, printPreview, registerScrollEditor, - scrollPreview, clearPreview) - -component :: Component Props -component = createComponent "App" - -type Props = {} - -data Action = OpenMetaEdit Boolean - | ExitMetaEditor String - | RenderPreview - | SplitChange ViewSplit - | Paginate Boolean - | TextChange String - | FileSaved String - | FileLoaded String String - - -renderPreview :: forall t. { split :: ViewSplit - , paginated :: Boolean - | t } -> Effect Unit -renderPreview state = - if state.split == OnlyEditor - then clearPreview - else renderMd state.paginated - - -app :: Props -> JSX -app = make component - { initialState: - { text: "" - , fileName: "Untitled" - , fileDirty: false - , metaEditorOpen: false - , split: OnlyEditor - , paginated: false - } - - , didMount: \self -> do - let splitChange = send self <<< SplitChange - Ipc.on "splitViewOnlyEditor" $ splitChange OnlyEditor - Ipc.on "splitViewSplit" $ splitChange Split - Ipc.on "splitViewOnlyPreview" $ splitChange OnlyPreview - initFile - { onFileLoad: \name txt -> send self $ FileLoaded name txt - , onFileSave: send self <<< FileSaved - } - Ipc.on "addBold" $ CodeMirror.replaceSelection Formatter.bold - Ipc.on "addItalic" $ CodeMirror.replaceSelection Formatter.italic - Ipc.on "addStrikethrough" $ CodeMirror.replaceSelection Formatter.strikethrough - - , update: \{state} action -> case action of - OpenMetaEdit o -> Update state {metaEditorOpen = o} - SplitChange sp -> UpdateAndSideEffects state {split = sp} - \self -> do - renderPreview self.state - CodeMirror.refresh - Paginate p -> UpdateAndSideEffects state {paginated = p} - \self -> renderMd p - TextChange txt -> UpdateAndSideEffects state {text = txt, fileDirty = true} - \self -> do - setWindowDirty - updateDocument txt - renderPreview self.state - ExitMetaEditor txt -> UpdateAndSideEffects state {text = txt, metaEditorOpen = false} - \self -> do - renderPreview self.state - CodeMirror.refresh - RenderPreview -> UpdateAndSideEffects state - \self -> do - renderPreview self.state - FileSaved name -> Update state {fileName = name, fileDirty = false} - FileLoaded name txt -> UpdateAndSideEffects state - { text = txt - , fileName = name - , fileDirty = false - } - \self -> do - updateDocument txt - renderPreview self.state - - , render: \self@{state} -> - R.div { - className: case state.split of - OnlyEditor -> "app onlyeditor" - Split -> "app split" - OnlyPreview -> "app onlypreview" - , children: [ - toolbar - { fileName: state.fileName - , fileDirty: state.fileDirty - , metaEditorOpen: state.metaEditorOpen - , onMetaEditorChange: capture_ self <<< OpenMetaEdit - , split: state.split - , onSplitChange: capture_ self <<< SplitChange - , paginated: state.paginated - , onPaginatedChange: capture_ self <<< Paginate - } - , R.div - { className: "editor" - , children: [ - guard state.metaEditorOpen $ metaEditor - { onBack: send self <<< ExitMetaEditor - , onChange: send self $ RenderPreview - } - , CodeMirror.controlled - { onBeforeChange: send self <<< TextChange - , onScroll: scrollPreview - , onEditorDidMount: registerScrollEditor - , value: state.text - , autoCursor: true - , options: - { mode: - { name: "yaml-frontmatter" - , base: "markdown" - } - , theme: "paper" - , indentUnit: 4 -- because of how numbered lists behave in CommonMark - , tabSize: 4 - , lineNumbers: false - , lineWrapping: true - , autofocus: true - , extraKeys: - { "Enter": "newlineAndIndentContinueMarkdownList" - , "Tab": "indentMore" - , "Shift-Tab": "indentLess" - } - } - } - ] - } - , preview - { paginated: state.paginated - , printPreview: printPreview - } - ] - } - } diff --git a/src/Panwriter/Button.purs b/src/Panwriter/Button.purs deleted file mode 100644 index 7636269..0000000 --- a/src/Panwriter/Button.purs +++ /dev/null @@ -1,21 +0,0 @@ -module Panwriter.Button where - -import Data.Monoid (guard) - -import React.Basic (JSX) -import React.Basic.DOM as R -import React.Basic.Events (EventHandler) - -type Props = { - active :: Boolean -, children :: Array JSX -, onClick :: EventHandler -} - -button :: Props -> JSX -button props = - R.button - { className: guard props.active "active" - , children: props.children - , onClick: props.onClick - } diff --git a/src/Panwriter/Document.js b/src/Panwriter/Document.js deleted file mode 100644 index 668f8d8..0000000 --- a/src/Panwriter/Document.js +++ /dev/null @@ -1,62 +0,0 @@ -"use strict"; - -var Document = require('../../src/js/Document') - , jsYaml = require('js-yaml') - ; - -// from https://github.com/dworthen/js-yaml-front-matter/blob/master/src/index.js#L14 -var yamlFrontRe = /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?([\w\W]*)*/; - - -exports.getDocument = Document.getDoc; - -exports.defaultVars = Document.defaultVars; - -exports.setMeta = function(metaObj) { - return function() { - for (var key in metaObj) { - if (metaObj[key] === '') { - delete metaObj[key] - } - } - Document.setMeta(metaObj) - } -} - -exports.writeMetaToDoc = function() { - var metaObj = Document.getMeta(); - var bodyStr = Document.getBodyMd(); - var yamlStr = Object.keys(metaObj).length > 0 - ? jsYaml.safeDump(metaObj, {skipInvalid: true}) - : ''; - var mdStr = (yamlStr ? '---\n' + yamlStr + '---\n\n' : '') - + bodyStr.trim(); - Document.setDoc(mdStr, yamlStr, bodyStr, metaObj); - return mdStr -} - -exports.updateDocument = function(mdStr) { - return function() { - var yamlStr = '' - , bodyStr = mdStr - , metaObj = {} - , yaml - , results = yamlFrontRe.exec(mdStr) - ; - try { - if (yaml = results[2]) { - var meta = jsYaml.safeLoad(yaml, {schema: jsYaml.JSON_SCHEMA}); - if (typeof meta === 'object' && !(meta instanceof Array) ) { - yamlStr = yaml - bodyStr = results[3] || ''; - metaObj = meta - } else { - console.warn("YAML wasn't an object"); - } - } - } catch(e) { - console.warn("Could not parse YAML", e.message); - } - Document.setDoc(mdStr, yamlStr, bodyStr, metaObj); - } -} diff --git a/src/Panwriter/Document.purs b/src/Panwriter/Document.purs deleted file mode 100644 index 9b1d477..0000000 --- a/src/Panwriter/Document.purs +++ /dev/null @@ -1,29 +0,0 @@ -module Panwriter.Document where - -import Prelude -import Effect (Effect) -import Data.Argonaut.Core as A -import Foreign.Object as O - -type Meta = O.Object A.Json - -type Document = { - md :: String -, yaml :: String -, bodyMd :: String -, meta :: Meta -, html :: String -} - - -foreign import getDocument :: Effect Document - -foreign import defaultVars :: Meta - -foreign import setMeta :: Meta -> Effect Unit - -foreign import writeMetaToDoc :: Effect String - --- note: this does not cause a rerender -foreign import updateDocument :: String -- ^ markdown string - -> Effect Unit diff --git a/src/Panwriter/File.js b/src/Panwriter/File.js deleted file mode 100644 index 02a6820..0000000 --- a/src/Panwriter/File.js +++ /dev/null @@ -1,93 +0,0 @@ -"use strict"; - -var ipcRenderer = require('electron').ipcRenderer - , remote = require('electron').remote - , fs = require('fs') - , path = require('path') - , Document = require('../../src/js/Document') - , Importer = require('../../src/js/Importer') - ; - -var onFileSaveCb; - -exports.initFile = function(conf) { - return function() { - onFileSaveCb = conf.onFileSave; - - var fileLoaded = function(text) { - win.fileIsDirty = false; - conf.onFileLoad(name)(text)(); - } - , win = remote.getCurrentWindow() - , filePath = Document.getPath() - ; - - if (filePath) { - var name = filename(filePath); - if (win.isFileToImport) { - // import file - Importer.importFile(filePath, fileLoaded); - } else { - // open file - fs.readFile(filePath, "utf8", function(err, text) { - if (err) { - alert("Could not open file.\n" + err.message); - win.close(); - } else { - win.setTitle(name); - win.setRepresentedFilename(filePath); - fileLoaded(text); - } - }); - } - } - }; -}; - -exports.setWindowDirty = function() { - var win = remote.getCurrentWindow(); - win.fileIsDirty = true; -} - -ipcRenderer.on('fileSave', function(_event, opts) { - if (opts === undefined) { - opts = {}; - } - var filePath = Document.getPath(); - var filePathPromise = filePath === undefined || opts.saveAsNewFile - ? remote.dialog.showSaveDialog(remote.getCurrentWindow(), { - defaultPath: 'Untitled.md' - , filters: [ - { name: 'Markdown', extensions: ['md', 'txt', 'markdown'] } - ] - }) - : filePathPromise = Promise.resolve({filePath: filePath}); - - filePathPromise.then(function(res) { - filePath = res.filePath - if (!filePath) { - return; - } - fs.writeFile(filePath, Document.getMd(), function(err){ - if (err) { - alert("Could not save file.\n" + err.message); - } else { - var win = remote.getCurrentWindow() - , name = filename(filePath) - ; - Document.setPath(filePath); - win.setTitle(name); - win.setRepresentedFilename(filePath); - win.fileIsDirty = false; - onFileSaveCb(name)(); - if (opts.closeWindowAfterSave) { - win.close(); - } - } - }); - }) -}); - -function filename(filePath) { - return path.basename(filePath, path.extname(filePath)); -} diff --git a/src/Panwriter/File.purs b/src/Panwriter/File.purs deleted file mode 100644 index 4c3eee0..0000000 --- a/src/Panwriter/File.purs +++ /dev/null @@ -1,14 +0,0 @@ -module Panwriter.File where - -import Prelude -import Effect (Effect) - -type Filename = String - -foreign import initFile :: - { onFileLoad :: Filename -> String -> Effect Unit - , onFileSave :: Filename -> Effect Unit - } - -> Effect Unit - -foreign import setWindowDirty :: Effect Unit diff --git a/src/Panwriter/Formatter.purs b/src/Panwriter/Formatter.purs deleted file mode 100644 index 07e75a0..0000000 --- a/src/Panwriter/Formatter.purs +++ /dev/null @@ -1,16 +0,0 @@ -module Panwriter.Formatter ( - bold -, italic -, strikethrough -) where - -import Prelude - -bold :: String -> String -bold txt = "**" <> txt <> "**" - -italic :: String -> String -italic txt = "_" <> txt <> "_" - -strikethrough :: String -> String -strikethrough txt = "~~" <> txt <> "~~" diff --git a/src/Panwriter/MetaEditor.purs b/src/Panwriter/MetaEditor.purs deleted file mode 100644 index b5cdef3..0000000 --- a/src/Panwriter/MetaEditor.purs +++ /dev/null @@ -1,212 +0,0 @@ -module Panwriter.MetaEditor where - -import Prelude -import Control.Alt ((<|>)) -import Data.Int.Parse (parseInt) -import Data.Argonaut.Core as A -import Data.Array (concatMap) -import Data.Maybe (fromMaybe) -import Data.String (null, stripPrefix, stripSuffix, Pattern(..)) -import Effect (Effect) -import Foreign.Object as O -import React.Basic (Self, Component, JSX, StateUpdate(..), createComponent, make, capture, capture_, send) -import React.Basic.DOM as R -import React.Basic.DOM.Events (targetValue) -import Panwriter.Document (getDocument, defaultVars, setMeta, writeMetaToDoc, Meta) -import Panwriter.File (setWindowDirty) -import React.Basic.ColorPicker (colorPicker) - - -type Kv = { - label :: String -, name :: String -, type :: FieldType -, placeholder :: String -} - -data FieldType = String - | Textarea { onLoad :: String -> String, onDone :: String -> String } - | Number { onLoad :: String -> String, onDone :: String -> String, step :: String } - | Select { options :: Array String } - | Color - -removeWrappingStyle :: String -> String -removeWrappingStyle s = fromMaybe s mbStripped - where - mbStripped = stripPrefix (Pattern "") - -addWrappingStyle :: String -> String -addWrappingStyle "" = "" -addWrappingStyle s = "" - -appendPx :: String -> String -appendPx "" = "" -appendPx s = s <> "px" - -metaKvs :: Array Kv -metaKvs = [{ - name: "title" -, label: "Title" -, type: String -, placeholder: "" -}, { - name: "author" -, label: "Author" -, type: String -, placeholder: "" -}, { - name: "date" -, label: "Date" -, type: String -, placeholder: "" -}, { - name: "lang" -, label: "Language" -, type: String -, placeholder: "en" -}] - -layoutKvs :: Array Kv -layoutKvs = [{ - name: "mainfont" -, label: "Font" -, type: Select { - options: [ - "" - , "Georgia, serif" - , "Helvetica, Arial, sans-serif" - , "Palatino, Palatino Linotype, serif" - ] - } -, placeholder: "" -}, { - name: "fontsize" -, label: "Font size" -, type: Number { step: "1", onLoad: \s -> fromMaybe "" (show <$> parseInt s), onDone: appendPx } -, placeholder: "" -}, { - name: "linestretch" -, label: "Line height" -, type: Number { step: "0.1", onLoad: identity, onDone: identity } -, placeholder: "" -}, { - name: "fontcolor" -, label: "Font color" -, type: Color -, placeholder: "" -}, { - name: "linkcolor" -, label: "Link color" -, type: Color -, placeholder: "" -}, { - name: "monobackgroundcolor" -, label: "Code bg" -, type: Color -, placeholder: "" -}, { - name: "backgroundcolor" -, label: "Background" -, type: Color -, placeholder: "" -}, { - name: "header-includes" -, label: "Include CSS" -, type: Textarea { onLoad: removeWrappingStyle , onDone: addWrappingStyle} -, placeholder: """blockquote { - font-style: italic; -}""" -}] - -type Props = { - onBack :: String -> Effect Unit -, onChange :: Effect Unit -} - -component :: Component Props -component = createComponent "MetaEditor" - -renderKv :: Self Props {meta :: Meta} Action -> Kv -> Array JSX -renderKv self kv = [ - R.label - { htmlFor: kv.name - , children: [R.text $ kv.label <> ":"] - } -, case kv.type of - String -> R.input { id: kv.name, value: v, onChange: onChange identity, placeholder: p, type: "text" } - Textarea t -> R.textarea { id: kv.name, value: t.onLoad v, onChange: onChange t.onDone, placeholder: p } - Number n -> R.input { id: kv.name, value: n.onLoad v, onChange: onChange n.onDone, placeholder: p, type: "number", step: n.step } - Select s -> R.select { id: kv.name, value: v, onChange: onChange identity, children: optsToJsx s.options } - Color -> colorPicker { id: kv.name, value: v, onChange: \c -> send self (SetMetaValue kv.name c) } -] - where - p = kv.placeholder - v = fromMaybe "" $ (lookup self.state.meta <|> lookup defaultVars) >>= A.toString - lookup = O.lookup kv.name - onChange fn = capture self targetValue (\mv -> SetMetaValue kv.name $ fn $ fromMaybe "" mv) - optsToJsx opts = map fn opts - where - fn o = R.option { value: o, children: [R.text $ if null o then "System font, sans-serif" else o] } - -data Action = SetMeta Meta - | SetMetaValue String String - | SaveAndExit - -metaEditor :: Props -> JSX -metaEditor = make component - { initialState: { meta: O.empty } - , didMount: \self -> do - doc <- getDocument - send self $ SetMeta doc.meta - , update: \{state} action -> case action of - SetMeta m -> Update state { meta = m } - SetMetaValue k v -> let m = O.insert k (A.fromString v) state.meta - in UpdateAndSideEffects state { meta = m } - \self -> do - setMeta m - self.props.onChange - SaveAndExit -> UpdateAndSideEffects state - \self -> do - setWindowDirty - txt <- writeMetaToDoc - self.props.onBack txt - , render: \self -> - R.div - { className: "metaeditor" - , children: [ - R.button - { className: "backbtn" - , onClick: capture_ self SaveAndExit - , children: [ R.img - { alt: "back" - , src: "back.svg" - , draggable: "false" - } - ] - } - , R.div - { className: "content" - , children: [ - R.h4 - { children: [R.text "Document metadata"] - } - , R.div - { className: "kvs" - , children: concatMap (renderKv self) metaKvs - } - , R.h4 - { children: [R.text "Layout"] - } - , R.p - { className: "darkmodewarning" - , children: [R.text "Previewing custom colors in dark mode is not supported."] - } - , R.div - { className: "kvs" - , children: concatMap (renderKv self) layoutKvs - } - ] - } - ] - } - } diff --git a/src/Panwriter/Preview.purs b/src/Panwriter/Preview.purs deleted file mode 100644 index 4ba3b82..0000000 --- a/src/Panwriter/Preview.purs +++ /dev/null @@ -1,60 +0,0 @@ -module Panwriter.Preview where - -import Prelude -import Data.Monoid (guard) -import Effect (Effect) - -import React.Basic (Component, JSX, StateUpdate(..), capture_, createComponent, make) -import React.Basic.DOM as R -import React.Basic.Events as Events - -component :: Component Props -component = createComponent "Preview" - -type Props = { - paginated :: Boolean -, printPreview :: Effect Unit -} - -data Action = Zoom (Number -> Number -> Number) - -preview :: Props -> JSX -preview = make component - { initialState: - { previewScale: 1.0 - } - - , update: \{state} action -> case action of - Zoom op -> Update state {previewScale = op state.previewScale 0.125} - - , render: \self@{props, state} -> - R.div - { className: "preview" <> guard props.paginated " paginated" - , children: [ - R.div - { className: "previewDiv" - , style: R.css - { transform: "scale(" <> show state.previewScale <> ")" - , width: show (100.0 / state.previewScale) <> "%" - , height: show (100.0 / state.previewScale) <> "%" - , transformOrigin: "0 0" - } - } - , R.button - { className: "zoomBtn zoomIn" - , onClick: capture_ self $ Zoom (+) - , children: [R.text "+"] - } - , R.button - { className: "zoomBtn zoomOut" - , onClick: capture_ self $ Zoom (-) - , children: [R.text "-"] - } - , R.button - { className: "exportBtn" - , onClick: Events.handler_ props.printPreview - , children: [R.text "🖨"] - } - ] - } - } diff --git a/src/Panwriter/Toolbar.purs b/src/Panwriter/Toolbar.purs deleted file mode 100644 index 9c6590e..0000000 --- a/src/Panwriter/Toolbar.purs +++ /dev/null @@ -1,133 +0,0 @@ -module Panwriter.Toolbar where - -import Prelude -import Data.Functor (mapFlipped) - -import Data.Monoid (guard) -import Panwriter.Button (button) -import React.Basic (Component, JSX, StateUpdate(..), capture_, createComponent, make) -import React.Basic.DOM as R -import React.Basic.Events (EventHandler) -import Electron.CurrentWindow as CurrentWindow - -data ViewSplit = OnlyEditor | Split | OnlyPreview -derive instance eqViewSplit :: Eq ViewSplit - -component :: Component Props -component = createComponent "Toolbar" - -type Props = { - fileName :: String -, fileDirty :: Boolean -, metaEditorOpen :: Boolean -, onMetaEditorChange :: Boolean -> EventHandler -, split :: ViewSplit -, onSplitChange :: ViewSplit -> EventHandler -, paginated :: Boolean -, onPaginatedChange :: Boolean -> EventHandler -} - -data Action = Close - | Minimize - | Maximize - -showAction :: Action -> String -showAction Close = "close" -showAction Minimize = "minimize" -showAction Maximize = "maximize" - -toolbar :: Props -> JSX -toolbar = make component - { initialState: {} - , update: \{state} action -> case action of - Close -> UpdateAndSideEffects state \self -> CurrentWindow.close - Minimize -> UpdateAndSideEffects state \self -> CurrentWindow.minimize - Maximize -> UpdateAndSideEffects state \self -> CurrentWindow.maximize - , render: \self -> - let metaEditorBtn props = guard (props.split /= OnlyPreview) - button - { active: props.metaEditorOpen - , children: [ R.img - { alt: "Edit metadata" - , src: "metaEditor.svg" - } - ] - , onClick: props.onMetaEditorChange $ not props.metaEditorOpen - } - - paginatedBtn props = guard (props.split /= OnlyEditor) - R.div - { className: "" - , children: [ - button - { active: props.paginated - , children: [ R.img - { alt: "Paginated" - , src: "page.svg" - } - ] - , onClick: props.onPaginatedChange $ not props.paginated - } - ] - } - - splitBtns props = - R.div - { className: "btngroup" - , children: [ - splitButton OnlyEditor - , splitButton Split - , splitButton OnlyPreview - ] - } - where - splitButton split = - let splitIcon OnlyEditor = {alt: "Editor", src: "notes.svg"} - splitIcon Split = {alt: "Split", src: "vertical_split.svg"} - splitIcon OnlyPreview = {alt: "Preview", src: "visibility.svg"} - in button - { active: split == props.split - , children: [R.img $ splitIcon split] - , onClick: props.onSplitChange split - } - in R.div - { className: "toolbar" - , children: [ - R.div - { className: "toolbararea" - , children: [ - R.div - { className: "windowbuttons" - , children: mapFlipped [Close, Minimize, Maximize] \action -> - R.div { - onClick: capture_ self action - , children: [R.img { src: "macOS_window_" <> showAction action <> ".svg" }] - } - } - , R.div - { className: "leftbtns" - , children: [ - metaEditorBtn self.props - ] - } - , R.div - { className: "filename" - , children: [ - R.span - { children: [R.text self.props.fileName] - } - , R.span - { className: "edited" - , children: [R.text $ guard self.props.fileDirty " — Edited"] - } - ] - } - , R.div -- icons from https://material.io/tools/icons/ - { className: "btns" - , children: [paginatedBtn self.props <> splitBtns self.props] - } - ] - } - ] - } - } diff --git a/src/React/Basic/CodeMirror.js b/src/React/Basic/CodeMirror.js deleted file mode 100644 index cc49406..0000000 --- a/src/React/Basic/CodeMirror.js +++ /dev/null @@ -1,98 +0,0 @@ -"use strict"; - -var React = require('react') - , ReactCM2 = require('react-codemirror2') - , Controlled = ReactCM2.Controlled - , UnControlled = ReactCM2.UnControlled - , CodeMirror = require('codemirror') - , ipcRenderer = require('electron').ipcRenderer - ; - -require('codemirror/addon/dialog/dialog'); -require('codemirror/addon/search/search'); -require('codemirror/addon/search/searchcursor'); -require('codemirror/addon/search/jump-to-line'); -require('codemirror/addon/mode/overlay'); -require('codemirror/mode/markdown/markdown'); -require('codemirror/mode/yaml/yaml'); -require('codemirror/mode/yaml-frontmatter/yaml-frontmatter'); -require('codemirror/addon/edit/continuelist'); - -var editor - , onEditorDidMount = function(props, ed) { - editor = ed; - if (props.options.autofocus) { - editor.focus(); - } - - // adapted from https://codemirror.net/demo/indentwrap.html - var charWidth = editor.defaultCharWidth() - , basePadding = 4 - // matches markdown list `-`, `+`, `*`, `1.`, `1)` and blockquote `>` markers: - , listRe = /^(([-|\+|\*|\>]|\d+[\.|\)])\s+)(.*)/ - ; - editor.on("renderLine", function(cm, line, elt) { - var txt = line.text - , matches = txt.trim().match(listRe) - ; - if (matches && matches[1]) { - var extraIndent = matches[1].length - , columnCount = CodeMirror.countColumn(txt, null, cm.getOption("tabSize")) - , off = (columnCount + extraIndent) * charWidth - ; - elt.style.textIndent = "-" + off + "px"; - elt.style.paddingLeft = (basePadding + off) + "px"; - } - }); - editor.refresh(); - } - ; - -function adjustProps(props, changeHandlerName) { - var onChange = props[changeHandlerName] - , onScroll = props.onScroll - , onMount = props.onEditorDidMount - , moreProps = { - editorDidMount: function (ed) { - onEditorDidMount(props, ed); - onMount(ed)(); - } - , onScroll: onScroll - } - ; - moreProps[changeHandlerName] = function (ed, diffData, value) { - onChange(value)(); - } - return Object.assign(props, moreProps); -} - -exports.controlled = function(props) { - return React.createElement(Controlled, adjustProps(props, 'onBeforeChange')); -} - -exports.refresh = function() { - if (editor) { - // use timeout to prevent interaction with scroll-sync - setTimeout( function(){ - editor.refresh() - }, 0); - } -} - -exports.replaceSelection = function(fn) { - return function() { - if (editor) { - editor.replaceSelection( fn( editor.getSelection() ) ); - } - } -} - -ipcRenderer.on('find', function() { - editor.execCommand('findPersistent'); -}) -ipcRenderer.on('findNext', function() { - editor.execCommand('findPersistentNext'); -}) -ipcRenderer.on('findPrevious', function() { - editor.execCommand('findPersistentPrev'); -}) diff --git a/src/React/Basic/CodeMirror.purs b/src/React/Basic/CodeMirror.purs deleted file mode 100644 index c81be4f..0000000 --- a/src/React/Basic/CodeMirror.purs +++ /dev/null @@ -1,19 +0,0 @@ -module React.Basic.CodeMirror where - -import Prelude -import Effect (Effect) -import React.Basic (JSX) - -foreign import controlled :: forall a editor. - { value :: String - , onBeforeChange :: String -> Effect Unit - , onScroll :: Effect Unit - , onEditorDidMount :: editor -> Effect Unit - | a } - -> JSX - -foreign import refresh :: Effect Unit - --- triggers onChange -foreign import replaceSelection :: (String -> String) -- ^ replace function that receives selected text - -> Effect Unit diff --git a/src/React/Basic/ColorPicker.purs b/src/React/Basic/ColorPicker.purs deleted file mode 100644 index 4454f1e..0000000 --- a/src/React/Basic/ColorPicker.purs +++ /dev/null @@ -1,49 +0,0 @@ -module React.Basic.ColorPicker where - -import Prelude -import Data.Maybe (fromMaybe) -import Effect (Effect) -import React.Basic (Component, JSX, StateUpdate(..), createComponent, make, capture_) -import React.Basic.DOM as R -import React.Basic.DOM.Events (targetValue) -import React.Basic.Events (handler) -import React.Basic.ReactColor (reactColor) - -type Props = { - id :: String -, value :: String -, onChange :: String -> Effect Unit -} - -data Action = Focus | Blur - -component :: Component Props -component = createComponent "ColorPicker" - -colorPicker :: Props -> JSX -colorPicker = make component - { initialState: { showPicker: false } - , update: \{state} action -> case action of - Focus -> Update state { showPicker = true } - Blur -> Update state { showPicker = false } - , render: \self -> - let value = self.props.value - in R.div - { className: "colorpicker" - , children: [ - R.input - { id: self.props.id - , value: value - , onChange: handler targetValue (\mv -> self.props.onChange $ fromMaybe "" mv) - , onFocus: capture_ self Focus - } - , R.div { className: "rectangle", style: R.css {background: value}, onClick: capture_ self Focus } - ] <> - if self.state.showPicker - then [ - R.div { className: "background", onClick: capture_ self Blur } - , reactColor { color: value, onChange: self.props.onChange } - ] - else [] - } - } diff --git a/src/React/Basic/PreviewRenderer.js b/src/React/Basic/PreviewRenderer.js deleted file mode 100644 index 867243a..0000000 --- a/src/React/Basic/PreviewRenderer.js +++ /dev/null @@ -1,206 +0,0 @@ -"use strict"; - -var ipcRenderer = require('electron').ipcRenderer - , Document = require('../../src/js/Document') - , Renderers = require('../../src/js/Renderers') - , mdItPandoc = require('markdown-it-pandoc')() - , throttle = require('../../src/js/throttle').throttle - ; - -var renderInProgress = false - , needsRerender = false - , paginated = false - , previewDiv - , editor - , editorOffset = 0 - , scrollEditorFn - , scrollMap - , reverseScrollMap - , frameWindow - , scrollSyncTimeout // shared between scrollPreview and scrollEditor - ; - -exports.printPreview = function() { - if (frameWindow) { - frameWindow.print(); - } -}; - -ipcRenderer.on('filePrint', exports.printPreview); - -exports.clearPreview = function() { - frameWindow = undefined; -} - -exports.scrollPreview = throttle( function() { - if (frameWindow) { - if (!scrollMap) { - buildScrollMap(editor, editorOffset); - } - var scrollTop = Math.round(editor.getScrollInfo().top) - , scrollTo = scrollMap[scrollTop] - ; - if (scrollTo !== undefined && frameWindow) { - frameWindow.scrollTo(0, scrollTo); - } - } -}, 30, scrollSyncTimeout); - -exports.registerScrollEditorImpl = function(ed) { - editor = ed; - editorOffset = parseInt(window.getComputedStyle( - document.querySelector('.CodeMirror-lines') - ).getPropertyValue('padding-top'), 10) - var editorScrollFrame = document.querySelector('.CodeMirror-scroll') - - scrollEditorFn = throttle( function(e) { - e.preventDefault(); - if (frameWindow !== undefined) { - if (!reverseScrollMap) { - buildScrollMap(editor, editorOffset); - } - for (var i=frameWindow.scrollY; i>=0; i--) { - if (reverseScrollMap[i] !== undefined) { - editorScrollFrame.scrollTo(0, reverseScrollMap[i]) - break; - } - } - } - }, 30, scrollSyncTimeout); -} - -exports.renderMd = function(isPaginated) { - return function() { - needsRerender = true; - paginated = isPaginated; - renderNext(); - } -}; - -function buildScrollMap(editor, editorOffset) { - // scrollMap maps source-editor-line-offsets to preview-element-offsets - // (offset is the number of vertical pixels from the top) - scrollMap = []; - scrollMap[0] = 0; - reverseScrollMap = []; - - // lineOffsets[i] holds top-offset of line i in the source editor - var lineOffsets = [undefined, 0] - , knownLineOffsets = [] - , offsetSum = 0 - ; - editor.eachLine( function(line) { - offsetSum += line.height; - lineOffsets.push(offsetSum); - }); - - var lastEl - , selector = paginated ? '.pagedjs_page_content [data-source-line]' - : 'body > [data-source-line]' - ; - frameWindow.document.querySelectorAll(selector).forEach( function(el){ - // for each element in the preview with source annotation - var line = parseInt(el.getAttribute('data-source-line'), 10) - , lineOffset = lineOffsets[line] - , elOffset = Math.round(el.getBoundingClientRect().top + frameWindow.scrollY); - ; - // fill in the target offset for the corresponding editor line - if (scrollMap[lineOffset] === undefined) { - // after pagination, we can have two elements in the preview - // that have the same source line. We only use the first. - scrollMap[lineOffset] = elOffset - editorOffset; - knownLineOffsets.push(lineOffset) - } - - lastEl = el; - }); - if (lastEl) { - scrollMap[offsetSum] = Math.ceil(lastEl.getBoundingClientRect().bottom + frameWindow.scrollY); - knownLineOffsets.push(offsetSum); - } - - if (knownLineOffsets[0] !== 0) { - // make sure line zero is in the list, to guarantee a smooth scrolling start - knownLineOffsets.unshift(0); - } - - // fill in the blanks by interpolating between the two closest known line offsets - var j = 0; - for (var i=1; i < offsetSum; i++) { - if (scrollMap[i] === undefined) { - var a = knownLineOffsets[j] - , b = knownLineOffsets[j + 1] - ; - scrollMap[i] = Math.round(( scrollMap[b]*(i - a) + scrollMap[a]*(b - i) ) / (b - a)); - } else { - j++; - } - reverseScrollMap[ scrollMap[i] ] = i; - } -} - -function resetScrollMaps () { - scrollMap = undefined; - reverseScrollMap = undefined; -} - -// buffers the latest text change and renders when previous rendering is done -function renderNext() { - if (needsRerender && !renderInProgress) { - renderInProgress = true; - render() - .catch( function(e) { - console.warn("renderer crashed", e.message, e.stack); - }) - .then(function(contentWindow){ - renderInProgress = false; - resetScrollMaps(); - frameWindow = contentWindow - if (frameWindow) { - frameWindow.addEventListener("resize", resetScrollMaps); - if (scrollEditorFn) { - frameWindow.addEventListener("scroll", scrollEditorFn); - } - } - renderNext(); - }); - needsRerender = false; - } -} - -function mdItSourceMap(nrLinesOffset) { - if (nrLinesOffset === undefined) { - nrLinesOffset = 1; - } - return function(md) { - var temp = md.renderer.renderToken.bind(md.renderer) - md.renderer.renderToken = function (tokens, idx, options) { - var token = tokens[idx] - if (token.level === 0 && token.map !== null && token.type.endsWith('_open')) { - token.attrPush(['data-source-line', token.map[0] + nrLinesOffset]) - } - return temp(tokens, idx, options) - } - } -} - -// takes a markdown str, renders it to preview and to Document.setHTML -async function render() { - var htmlStr = mdItPandoc - .use( mdItSourceMap(1 + Document.getNrOfYamlLines()) ) - .render( Document.getBodyMd() ) - ; - Document.setHtml(htmlStr); - - if (previewDiv) { - if (paginated) { - return Renderers.pagedjs(Document, previewDiv) - } else { - return Renderers.plain(Document, previewDiv) - } - } -} - -document.addEventListener("DOMContentLoaded", function() { - previewDiv = document.querySelector('.previewDiv'); -}); diff --git a/src/React/Basic/PreviewRenderer.purs b/src/React/Basic/PreviewRenderer.purs deleted file mode 100644 index 160fb64..0000000 --- a/src/React/Basic/PreviewRenderer.purs +++ /dev/null @@ -1,20 +0,0 @@ -module React.Basic.PreviewRenderer where - -import Prelude (Unit) - -import Effect (Effect) -import Effect.Uncurried (EffectFn1, runEffectFn1) - -foreign import renderMd :: Boolean -- ^ render paginated - -> Effect Unit - -foreign import printPreview :: Effect Unit - -foreign import registerScrollEditorImpl :: forall editor. EffectFn1 editor Unit - -registerScrollEditor :: forall editor. editor -> Effect Unit -registerScrollEditor = runEffectFn1 registerScrollEditorImpl - -foreign import scrollPreview :: Effect Unit - -foreign import clearPreview :: Effect Unit diff --git a/src/React/Basic/ReactColor.js b/src/React/Basic/ReactColor.js deleted file mode 100644 index 102a629..0000000 --- a/src/React/Basic/ReactColor.js +++ /dev/null @@ -1,16 +0,0 @@ -"use strict"; - -var React = require('react') - , ReactColor = require('react-color') - ; - -exports.reactColor = function(props) { - return React.createElement(ReactColor.SketchPicker, { - color: props.color - , disableAlpha: true - , presetColors: [] - , onChange: function(c) { - props.onChange(c.hex)(); - } - }); -} diff --git a/src/React/Basic/ReactColor.purs b/src/React/Basic/ReactColor.purs deleted file mode 100644 index f53886b..0000000 --- a/src/React/Basic/ReactColor.purs +++ /dev/null @@ -1,10 +0,0 @@ -module React.Basic.ReactColor where - -import Prelude -import Effect (Effect) -import React.Basic (JSX) - --- hex color string like #ffffff -type Color = String - -foreign import reactColor :: {color :: Color, onChange :: Color -> Effect Unit} -> JSX diff --git a/src/appState/Action.ts b/src/appState/Action.ts new file mode 100644 index 0000000..ba88521 --- /dev/null +++ b/src/appState/Action.ts @@ -0,0 +1,28 @@ +import { Doc, ViewSplit } from '../appState/AppState' + +export type Action = { + type: 'closeMetaEditorAndSetMd'; +} +| { + type: 'setMdAndRender'; + md: string; +} +| { + type: 'setMetaAndRender'; + key: string; + value: string; +} +| { + type: 'setSplitAndRender'; + split: ViewSplit; +} +| { + type: 'toggleMetaEditorOpen'; +} +| { + type: 'togglePaginated'; +} +| { + type: 'updateDoc'; + doc: Partial; +} diff --git a/src/appState/AppState.ts b/src/appState/AppState.ts new file mode 100644 index 0000000..98c10f6 --- /dev/null +++ b/src/appState/AppState.ts @@ -0,0 +1,36 @@ +import { RefObject } from 'react' + +export interface AppState { + doc: Doc; + metaEditorOpen: boolean; + split: ViewSplit; + paginated: boolean; + previewDivRef: RefObject; +} + +export interface Doc { + /** whole editor contents (in markdown) */ + md: string; + + /** part of `md` that's the yaml metadata */ + yaml: string; + + /** rest part of `md` */ + bodyMd: string; + + /** parsed yaml metadata */ + meta: Meta; + + /** bodyMd converted to HTML */ + html: string + + fileName?: string; + filePath?: string; + fileDirty: boolean; +} + +export type Meta = Record +export type JSON = string | number | boolean | null | Meta[] | { [key: string]: JSON }; + +export const viewSplits = ['onlyEditor', 'split', 'onlyPreview'] as const +export type ViewSplit = typeof viewSplits[number] diff --git a/src/appState/appStateReducer.ts b/src/appState/appStateReducer.ts new file mode 100644 index 0000000..d1cb484 --- /dev/null +++ b/src/appState/appStateReducer.ts @@ -0,0 +1,57 @@ +import { AppState } from './AppState' +import { refreshEditor } from '../renderPreview/scrolling' +import { parseYaml, serializeMetaToMd } from '../renderPreview/convertYaml' +import { Action } from './Action' + + +export const appStateReducer = (state: AppState, action: Action): AppState => { + switch (action.type) { + case 'closeMetaEditorAndSetMd': { + const doc = { + ...state.doc, + md: serializeMetaToMd(state.doc), + fileDirty: true + } + return { ...state, doc } + } + case 'setMdAndRender': { + const { md } = action + const doc = { + ...state.doc, + ...parseYaml(md), + md, + fileDirty: true + } + return { ...state, doc } + } + case 'setMetaAndRender': { + const { key, value } = action + const doc = { ...state.doc } + doc.meta[key] = value + // doc.fileDirty = true + return { ...state, doc } + } + case 'setSplitAndRender': { + const { split } = action + let { doc } = state + if (split !== 'onlyEditor') { + // for the case when the preview is shown for the first time + doc = { ...doc, ...parseYaml(doc.md) } + } + if (split !== 'onlyPreview') { + setTimeout(refreshEditor) + } + return { ...state, doc, split } + } + case 'toggleMetaEditorOpen': { + return { ...state, metaEditorOpen: !state.metaEditorOpen } + } + case 'togglePaginated': { + return { ...state, paginated: !state.paginated } + } + case 'updateDoc': { + const doc = { ...state.doc, ...action.doc } + return { ...state, doc } + } + } +} diff --git a/static/preview.pandoc-styles.css b/src/assets/preview.pandoc-styles.css similarity index 91% rename from static/preview.pandoc-styles.css rename to src/assets/preview.pandoc-styles.css index c7a2a51..c61f344 100644 --- a/static/preview.pandoc-styles.css +++ b/src/assets/preview.pandoc-styles.css @@ -1,6 +1,6 @@ -/* pandoc's default CSS with nested if blocks etc. removed - * and `mainfont` and `monobackgroundcolor` defaults changed - * and dark mode added +/* pandoc's default CSS with nested if blocks etc. removed (see panwriter's templates.ts), + * `mainfont` and `monobackgroundcolor` defaults changed, + * as well as dark-mode and -webkit-print-color-adjust added. */ html { @@ -154,6 +154,11 @@ header { border-color: #d3d3d3; } } +* { + /* force printing of CSS backgrounds like we use e.g. for code blocks */ + -webkit-print-color-adjust: exact; +} + code{white-space: pre-wrap;} span.smallcaps{font-variant: small-caps;} span.underline{text-decoration: underline;} diff --git a/static/app.css b/src/components/App/App.css similarity index 77% rename from static/app.css rename to src/components/App/App.css index 14a5114..dfd8faa 100644 --- a/static/app.css +++ b/src/components/App/App.css @@ -122,51 +122,3 @@ select:focus { .metaeditor + .react-codemirror2 { display: none; } - -.preview { - overflow: hidden; - position: relative; - border-left: 1px solid var(--separator-color); -} -.preview.paginated > .previewDiv { - background-color: var(--underlay-background-color); -} -.preview iframe { - border: 0; - position: absolute; -} -.preview > button { - width: 36px; - height: 36px; - border-radius: 18px; - box-shadow: 0 1px #0000000a; - transition: opacity 0.2s ease-out; - opacity: 0; -} -.preview > button:active { - background: var(--button-active-color); -} -.preview:hover > button { - transition: opacity 0.12s ease-in; - opacity: 1; -} -.zoomBtn, .exportBtn { - position: absolute; - bottom: 10px; - right: 10px; - font-size: 27px; - color: var(--filename-font-color); - background: var(--background-color); - border: 0; - cursor: pointer; - line-height: 1; - display: block; -} -.zoomIn { - bottom: 55px; -} -.exportBtn { - bottom: 100px; - font-family: sans-serif; - font-size: 20px; -} diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx new file mode 100644 index 0000000..2cf669c --- /dev/null +++ b/src/components/App/App.tsx @@ -0,0 +1,64 @@ +import { createRef, useEffect, useReducer } from 'react' + +import { AppState } from '../../appState/AppState' +import { appStateReducer } from '../../appState/appStateReducer' + +import { Editor } from '../Editor/Editor' +import { MetaEditor } from '../MetaEditor/MetaEditor' +import { Preview } from '../Preview/Preview' +import { Toolbar } from '../Toolbar/Toolbar' +import { IpcApi } from '../../../electron/preload' +import { renderPreview } from '../../renderPreview/renderPreview' + +import './App.css' + +declare global { + interface Window { + ipcApi?: IpcApi; // optional in order to keep ability to run React app without Electron + } +} + +export const App = () => { + const [state, dispatch] = useReducer(appStateReducer, initialState) + window.ipcApi?.setStateAndDispatch(state, dispatch) + + useEffect(() => { + if (state.split !== 'onlyEditor') { + renderPreview(state) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.doc, state.split, state.paginated]) + + return ( +
+ +
+ { state.metaEditorOpen + ? + : null } + +
+ +
+ ); +} + +const initialState: AppState = { + doc: { + md: '' + , yaml: '' + , bodyMd: '' + , meta: {} + , html: '' + , fileName: 'Untitled' + , filePath: undefined + , fileDirty: false + } +, metaEditorOpen: false +, split: 'onlyEditor' +, paginated: false +, previewDivRef: createRef() +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..ed57a83 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,13 @@ +interface Props { + active: boolean; + children: JSX.Element | JSX.Element[]; + onClick: () => void; +} + +export const Button = (props: Props) => + diff --git a/static/app.colorpicker.css b/src/components/ColorPicker/ColorPicker.css similarity index 100% rename from static/app.colorpicker.css rename to src/components/ColorPicker/ColorPicker.css diff --git a/src/components/ColorPicker/ColorPicker.tsx b/src/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 0000000..4fa06d5 --- /dev/null +++ b/src/components/ColorPicker/ColorPicker.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react' +import { SketchPicker } from 'react-color' + +import './ColorPicker.css' + +interface Props { + id: string; + value: string; + onChange: (s: string) => void; +} + +export const ColorPicker = (props: Props) => { + const { id, value, onChange } = props + const [showPicker, setShowPicker] = useState(false) + const focus = () => setShowPicker(true) + return ( +
+ onChange(e.target.value)} + /> +
+ { showPicker + ? <> +
setShowPicker(false)} /> + onChange(c.hex)} + /> + + : null } +
+ ) +} diff --git a/static/editor.css b/src/components/Editor/Editor.css similarity index 96% rename from static/editor.css rename to src/components/Editor/Editor.css index 613f81e..0040da0 100644 --- a/static/editor.css +++ b/src/components/Editor/Editor.css @@ -1,4 +1,4 @@ -/* overrides for https://github.com/codemirror/CodeMirror/blob/master/lib/codemirror.css */ +@import './codemirror.css'; /* https://github.com/codemirror/CodeMirror/blob/master/lib/codemirror.css */ .CodeMirror { font-family: var(--mono-font); diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx new file mode 100644 index 0000000..9bf6e55 --- /dev/null +++ b/src/components/Editor/Editor.tsx @@ -0,0 +1,94 @@ +import { countColumn, Editor as CMEditor } from 'codemirror' +import 'codemirror/addon/dialog/dialog' +import 'codemirror/addon/search/search' +import 'codemirror/addon/search/searchcursor' +import 'codemirror/addon/search/jump-to-line' +import 'codemirror/addon/mode/overlay' +import 'codemirror/mode/markdown/markdown' +import 'codemirror/mode/yaml/yaml' +import 'codemirror/mode/yaml-frontmatter/yaml-frontmatter' +import 'codemirror/addon/edit/continuelist' +import { Controlled as CodeMirror } from 'react-codemirror2' + +import { AppState } from '../../appState/AppState' +import { Action } from '../../appState/Action' +import { registerScrollEditor, scrollPreview } from '../../renderPreview/scrolling' + +import './Editor.css' + +interface Props { + state: AppState; + dispatch: React.Dispatch; +} + +export const Editor = (props: Props) => { + const { state, dispatch } = props + return ( + + dispatch({ type: 'setMdAndRender', md }) + } + onScroll={scrollPreview} + editorDidMount={onEditorDidMount} + value={state.doc.md} + autoCursor={true} + options={codeMirrorOptions} + /> + ) +} + +const codeMirrorOptions = { + mode: { + name: 'yaml-frontmatter' + , base: 'markdown' + } +, theme: 'paper' +, indentUnit: 4 // because of how numbered lists behave in CommonMark +, tabSize: 4 +, lineNumbers: false +, lineWrapping: true +, autofocus: true +, extraKeys: { + Enter: 'newlineAndIndentContinueMarkdownList' + , Tab: 'indentMore' + , 'Shift-Tab': 'indentLess' + } +} + +const onEditorDidMount = (editor: CMEditor) => { + editor.focus(); + + // adapted from https://codemirror.net/demo/indentwrap.html + const charWidth = editor.defaultCharWidth() + const basePadding = 4 + // matches markdown list `-`, `+`, `*`, `1.`, `1)` and blockquote `>` markers: + // eslint-disable-next-line no-useless-escape + const listRe = /^(([-|\+|\*|\>]|\d+[\.|\)])\s+)(.*)/ + + editor.on('renderLine', (cm, line, elt) => { + const txt = line.text + const matches = txt.trim().match(listRe) + if (matches && matches[1]) { + const extraIndent = matches[1].length + const columnCount = countColumn(txt, null, cm.getOption('tabSize') || 4) + const off = (columnCount + extraIndent) * charWidth + elt.style.textIndent = '-' + off + 'px'; + elt.style.paddingLeft = (basePadding + off) + 'px'; + } + }); + editor.refresh(); + + registerScrollEditor(editor); + + window.ipcApi?.on.find( () => editor.execCommand('findPersistent')) + window.ipcApi?.on.findNext( () => editor.execCommand('findPersistentNext')) + window.ipcApi?.on.findPrevious( () => editor.execCommand('findPersistentPrev')) + + + const replaceSelection = (fn: (s: string) => string) => + editor.replaceSelection( fn( editor.getSelection() ) ) + + window.ipcApi?.on.addBold( () => replaceSelection(s => ['**', s, '**'].join('')) ) + window.ipcApi?.on.addItalic( () => replaceSelection(s => ['_', s, '_' ].join('')) ) + window.ipcApi?.on.addStrikethrough( () => replaceSelection(s => ['~~', s, '~~'].join('')) ) +} diff --git a/src/components/Editor/codemirror.css b/src/components/Editor/codemirror.css new file mode 100644 index 0000000..5ea2d2b --- /dev/null +++ b/src/components/Editor/codemirror.css @@ -0,0 +1,350 @@ +/* BASICS */ + +.CodeMirror { + /* Set height, width, borders, and global font properties here */ + font-family: monospace; + height: 300px; + color: black; + direction: ltr; +} + +/* PADDING */ + +.CodeMirror-lines { + padding: 4px 0; /* Vertical padding around content */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + padding: 0 4px; /* Horizontal padding of content */ +} + +.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + background-color: transparent; /* The little square between H and V scrollbars */ +} + +/* GUTTER */ + +.CodeMirror-gutters { + border-right: 1px solid #ddd; + background-color: #f7f7f7; + white-space: nowrap; +} +.CodeMirror-linenumbers {} +.CodeMirror-linenumber { + padding: 0 3px 0 5px; + min-width: 20px; + text-align: right; + color: #999; + white-space: nowrap; +} + +.CodeMirror-guttermarker { color: black; } +.CodeMirror-guttermarker-subtle { color: #999; } + +/* CURSOR */ + +.CodeMirror-cursor { + border-left: 1px solid black; + border-right: none; + width: 0; +} +/* Shown when moving in bi-directional text */ +.CodeMirror div.CodeMirror-secondarycursor { + border-left: 1px solid silver; +} +.cm-fat-cursor .CodeMirror-cursor { + width: auto; + border: 0 !important; + background: #7e7; +} +.cm-fat-cursor div.CodeMirror-cursors { + z-index: 1; +} +.cm-fat-cursor-mark { + background-color: rgba(20, 255, 20, 0.5); + -webkit-animation: blink 1.06s steps(1) infinite; + -moz-animation: blink 1.06s steps(1) infinite; + animation: blink 1.06s steps(1) infinite; +} +.cm-animate-fat-cursor { + width: auto; + border: 0; + -webkit-animation: blink 1.06s steps(1) infinite; + -moz-animation: blink 1.06s steps(1) infinite; + animation: blink 1.06s steps(1) infinite; + background-color: #7e7; +} +@-moz-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@-webkit-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} + +/* Can style cursor different in overwrite (non-insert) mode */ +.CodeMirror-overwrite .CodeMirror-cursor {} + +.cm-tab { display: inline-block; text-decoration: inherit; } + +.CodeMirror-rulers { + position: absolute; + left: 0; right: 0; top: -50px; bottom: 0; + overflow: hidden; +} +.CodeMirror-ruler { + border-left: 1px solid #ccc; + top: 0; bottom: 0; + position: absolute; +} + +/* DEFAULT THEME */ + +.cm-s-default .cm-header {color: blue;} +.cm-s-default .cm-quote {color: #090;} +.cm-negative {color: #d44;} +.cm-positive {color: #292;} +.cm-header, .cm-strong {font-weight: bold;} +.cm-em {font-style: italic;} +.cm-link {text-decoration: underline;} +.cm-strikethrough {text-decoration: line-through;} + +.cm-s-default .cm-keyword {color: #708;} +.cm-s-default .cm-atom {color: #219;} +.cm-s-default .cm-number {color: #164;} +.cm-s-default .cm-def {color: #00f;} +.cm-s-default .cm-variable, +.cm-s-default .cm-punctuation, +.cm-s-default .cm-property, +.cm-s-default .cm-operator {} +.cm-s-default .cm-variable-2 {color: #05a;} +.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;} +.cm-s-default .cm-comment {color: #a50;} +.cm-s-default .cm-string {color: #a11;} +.cm-s-default .cm-string-2 {color: #f50;} +.cm-s-default .cm-meta {color: #555;} +.cm-s-default .cm-qualifier {color: #555;} +.cm-s-default .cm-builtin {color: #30a;} +.cm-s-default .cm-bracket {color: #997;} +.cm-s-default .cm-tag {color: #170;} +.cm-s-default .cm-attribute {color: #00c;} +.cm-s-default .cm-hr {color: #999;} +.cm-s-default .cm-link {color: #00c;} + +.cm-s-default .cm-error {color: #f00;} +.cm-invalidchar {color: #f00;} + +.CodeMirror-composing { border-bottom: 2px solid; } + +/* Default styles for common addons */ + +div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;} +div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;} +.CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); } +.CodeMirror-activeline-background {background: #e8f2ff;} + +/* STOP */ + +/* The rest of this file contains styles related to the mechanics of + the editor. You probably shouldn't touch them. */ + +.CodeMirror { + position: relative; + overflow: hidden; + background: white; +} + +.CodeMirror-scroll { + overflow: scroll !important; /* Things will break if this is overridden */ + /* 50px is the magic margin used to hide the element's real scrollbars */ + /* See overflow: hidden in .CodeMirror */ + margin-bottom: -50px; margin-right: -50px; + padding-bottom: 50px; + height: 100%; + outline: none; /* Prevent dragging from highlighting the element */ + position: relative; +} +.CodeMirror-sizer { + position: relative; + border-right: 50px solid transparent; +} + +/* The fake, visible scrollbars. Used to force redraw during scrolling + before actual scrolling happens, thus preventing shaking and + flickering artifacts. */ +.CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + position: absolute; + z-index: 6; + display: none; + outline: none; +} +.CodeMirror-vscrollbar { + right: 0; top: 0; + overflow-x: hidden; + overflow-y: scroll; +} +.CodeMirror-hscrollbar { + bottom: 0; left: 0; + overflow-y: hidden; + overflow-x: scroll; +} +.CodeMirror-scrollbar-filler { + right: 0; bottom: 0; +} +.CodeMirror-gutter-filler { + left: 0; bottom: 0; +} + +.CodeMirror-gutters { + position: absolute; left: 0; top: 0; + min-height: 100%; + z-index: 3; +} +.CodeMirror-gutter { + white-space: normal; + height: 100%; + display: inline-block; + vertical-align: top; + margin-bottom: -50px; +} +.CodeMirror-gutter-wrapper { + position: absolute; + z-index: 4; + background: none !important; + border: none !important; +} +.CodeMirror-gutter-background { + position: absolute; + top: 0; bottom: 0; + z-index: 4; +} +.CodeMirror-gutter-elt { + position: absolute; + cursor: default; + z-index: 4; +} +.CodeMirror-gutter-wrapper ::selection { background-color: transparent } +.CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent } + +.CodeMirror-lines { + cursor: text; + min-height: 1px; /* prevents collapsing before first draw */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + /* Reset some styles that the rest of the page might have set */ + -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0; + border-width: 0; + background: transparent; + font-family: inherit; + font-size: inherit; + margin: 0; + white-space: pre; + word-wrap: normal; + line-height: inherit; + color: inherit; + z-index: 2; + position: relative; + overflow: visible; + -webkit-tap-highlight-color: transparent; + -webkit-font-variant-ligatures: contextual; + font-variant-ligatures: contextual; +} +.CodeMirror-wrap pre.CodeMirror-line, +.CodeMirror-wrap pre.CodeMirror-line-like { + word-wrap: break-word; + white-space: pre-wrap; + word-break: normal; +} + +.CodeMirror-linebackground { + position: absolute; + left: 0; right: 0; top: 0; bottom: 0; + z-index: 0; +} + +.CodeMirror-linewidget { + position: relative; + z-index: 2; + padding: 0.1px; /* Force widget margins to stay inside of the container */ +} + +.CodeMirror-widget {} + +.CodeMirror-rtl pre { direction: rtl; } + +.CodeMirror-code { + outline: none; +} + +/* Force content-box sizing for the elements where we expect it */ +.CodeMirror-scroll, +.CodeMirror-sizer, +.CodeMirror-gutter, +.CodeMirror-gutters, +.CodeMirror-linenumber { + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +.CodeMirror-measure { + position: absolute; + width: 100%; + height: 0; + overflow: hidden; + visibility: hidden; +} + +.CodeMirror-cursor { + position: absolute; + pointer-events: none; +} +.CodeMirror-measure pre { position: static; } + +div.CodeMirror-cursors { + visibility: hidden; + position: relative; + z-index: 3; +} +div.CodeMirror-dragcursors { + visibility: visible; +} + +.CodeMirror-focused div.CodeMirror-cursors { + visibility: visible; +} + +.CodeMirror-selected { background: #d9d9d9; } +.CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; } +.CodeMirror-crosshair { cursor: crosshair; } +.CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; } +.CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; } + +.cm-searching { + background-color: #ffa; + background-color: rgba(255, 255, 0, .4); +} + +/* Used to force a border model for a node */ +.cm-force-border { padding-right: .1px; } + +@media print { + /* Hide the cursor when printing */ + .CodeMirror div.CodeMirror-cursors { + visibility: hidden; + } +} + +/* See issue #2901 */ +.cm-tab-wrap-hack:after { content: ''; } + +/* Help users use markselection to safely style text background */ +span.CodeMirror-selectedtext { background: none; } diff --git a/static/app.metaeditor.css b/src/components/MetaEditor/MetaEditor.css similarity index 100% rename from static/app.metaeditor.css rename to src/components/MetaEditor/MetaEditor.css diff --git a/src/components/MetaEditor/MetaEditor.tsx b/src/components/MetaEditor/MetaEditor.tsx new file mode 100644 index 0000000..a33ce61 --- /dev/null +++ b/src/components/MetaEditor/MetaEditor.tsx @@ -0,0 +1,172 @@ +import { Fragment } from 'react' +import { AppState } from '../../appState/AppState' +import { Action } from '../../appState/Action' +import { defaultVars } from '../../renderPreview/templates/getCss' +import { ColorPicker } from '../ColorPicker/ColorPicker' + +import back from './back.svg' +import './MetaEditor.css' + +type Kv = String | Textarea | Number | Select | Color; + +interface BaseKv { + name: string; + label: string; + placeholder?: string; + onLoad?: (v: string) => string; + onDone?: (v: string) => string; +} + +interface String extends BaseKv { + type: 'string'; +} +interface Textarea extends BaseKv { + type: 'textarea'; +} +interface Number extends BaseKv { + type: 'number'; + step: number; +} +interface Select extends BaseKv { + type: 'select'; + options: string[]; +} +interface Color extends BaseKv { + type: 'color'; +} + +interface Props { + state: AppState; + dispatch: React.Dispatch; +} + +export const MetaEditor = (props: Props) => { + const { state, dispatch } = props + const { doc } = state + const renderKv = (kv: Kv) => + + + { renderInput(kv) } + + + const renderInput = (kv: Kv): JSX.Element => { + const { onLoad, onDone, placeholder } = kv + const key = kv.name + const val = doc.meta[key]?.toString() || defaultVars[key] || '' + const value = onLoad ? onLoad(val) : val + const onChange = ( + e: string | React.ChangeEvent + ) => { + const v = typeof e === 'string' ? e : e.target.value + dispatch({ type: 'setMetaAndRender', key, value: onDone ? onDone(v) : v }) + } + const common = { id: kv.name, placeholder, value, onChange } + switch(kv.type) { + case 'string': return + case 'textarea': return