diff --git a/.electron-vue/webpack.renderer.config.js b/.electron-vue/webpack.renderer.config.js index 96ffeb75..300113e1 100644 --- a/.electron-vue/webpack.renderer.config.js +++ b/.electron-vue/webpack.renderer.config.js @@ -51,7 +51,7 @@ const rendererConfig = { } }, { - test: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme|headerFooterStyle)\.css$/, + test: /(theme\-chalk(?:\/|\\)index|exportStyle|katex|github\-markdown|prism[\-a-z]*|\.theme|headerFooterStyle)\.css$/, use: [ 'to-string-loader', 'css-loader' @@ -59,7 +59,7 @@ const rendererConfig = { }, { test: /\.css$/, - exclude: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme|headerFooterStyle)\.css$/, + exclude: /(theme\-chalk(?:\/|\\)index|exportStyle|katex|github\-markdown|prism[\-a-z]*|\.theme|headerFooterStyle)\.css$/, use: [ proMode ? MiniCssExtractPlugin.loader : 'style-loader', { loader: 'css-loader', options: { importLoaders: 1 } }, diff --git a/docs/PREFERENCES.md b/docs/PREFERENCES.md index 848b122f..0f3a60e7 100644 --- a/docs/PREFERENCES.md +++ b/docs/PREFERENCES.md @@ -51,6 +51,7 @@ Preferences can be controlled and modified in the settings window or via the `pr | listIndentation | String | 1 | The list indentation of sub list items or paragraphs, optional value `dfm`, `tab` or number 1~4 | | frontmatterType | String | `-` | The frontmatter type: `-` (YAML), `+` (TOML), `;` (JSON) or `{` (JSON) | | superSubScript | Boolean | `false` | Enable pandoc's markdown extension superscript and subscript. | +| footnote | Boolean | `false` | Enable pandoc's footnote markdown extension | #### Theme diff --git a/docs/dev/code/BLOCK_ADDITION_PROPERTY.md b/docs/dev/code/BLOCK_ADDITION_PROPERTY.md index 7fa03ee3..06671f2a 100644 --- a/docs/dev/code/BLOCK_ADDITION_PROPERTY.md +++ b/docs/dev/code/BLOCK_ADDITION_PROPERTY.md @@ -6,6 +6,8 @@ - languageInput + - footnoteInput + - codeContent (used in code block) - cellContent (used in table cell, it's parent must be th or td block) @@ -46,6 +48,8 @@ The container block of `table`, `html`, `block math`, `mermaid`,`flowchart`,`veg - table + - footnote + - html - multiplemath diff --git a/electron-builder.yml b/electron-builder.yml index 12b264c6..8d1a53bb 100755 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -19,6 +19,7 @@ files: - "!node_modules/vega-lite/build/vega-lite*.js.map" # Don't bundle build files - "!node_modules/@felixrieseberg/spellchecker/bin" +- "!node_modules/@hfelix/spellchecker/bin" - "!node_modules/ced/bin" - "!node_modules/ced/vendor" - "!node_modules/cld/bin" @@ -34,6 +35,7 @@ files: - "!node_modules/ced/build/vendor" # Don't bundle LGPL source files - "!node_modules/@felixrieseberg/spellchecker/vendor" +- "!node_modules/@hfelix/spellchecker/vendor" extraFiles: - "LICENSE" - from: "resources/THIRD-PARTY-LICENSES.txt" diff --git a/package.json b/package.json index ceabf11d..a1c7c10a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@hfelix/electron-localshortcut": "^3.1.1", - "@hfelix/electron-spellchecker": "^1.0.0-rc.1", + "@hfelix/electron-spellchecker": "^1.0.0-rc.3", "@octokit/rest": "^16.33.1", "arg": "^4.1.1", "axios": "^0.19.0", @@ -65,7 +65,7 @@ "joplin-turndown-plugin-gfm": "^1.0.11", "katex": "^0.11.1", "keyboard-layout": "^2.0.16", - "keytar": "^5.0.0-beta.3", + "keytar": "5.0.0-beta.4", "mermaid": "^8.4.0", "plist": "^3.0.1", "popper.js": "^1.16.0", @@ -113,8 +113,8 @@ "del": "^5.1.0", "devtron": "^1.4.0", "dotenv": "^8.2.0", - "electron": "^6.1.0", - "electron-builder": "^21.2.0", + "electron": "7.0.0", + "electron-builder": "^22.1.0", "electron-devtools-installer": "^2.2.4", "electron-rebuild": "^1.8.6", "electron-updater": "^4.1.2", @@ -153,7 +153,7 @@ "postcss-preset-env": "^6.6.0", "raw-loader": "^3.1.0", "require-dir": "^1.2.0", - "spectron": "^8.0.0", + "spectron": "^9.0.0", "style-loader": "^1.0.0", "svg-sprite-loader": "^4.1.6", "svgo": "^1.3.0", @@ -171,9 +171,6 @@ "webpack-hot-middleware": "^2.25.0", "webpack-merge": "^4.2.1" }, - "optionalDependencies": { - "vscode-windows-registry": "^1.0.2" - }, "repository": { "type": "git", "url": "git@github.com:marktext/marktext.git" diff --git a/src/main/app/index.js b/src/main/app/index.js index 2954984d..981faa95 100644 --- a/src/main/app/index.js +++ b/src/main/app/index.js @@ -3,7 +3,7 @@ import fse from 'fs-extra' import { exec } from 'child_process' import dayjs from 'dayjs' import log from 'electron-log' -import { app, BrowserWindow, clipboard, dialog, ipcMain, systemPreferences } from 'electron' +import { app, BrowserWindow, clipboard, dialog, ipcMain, nativeTheme } from 'electron' import { isChildOfDirectory } from 'common/filesystem/paths' import { isLinux, isOsx, isWindows } from '../config' import parseArgs from '../cli/parser' @@ -115,7 +115,7 @@ class App { const { paths } = this._accessor ensureDefaultDict(paths.userDataPath) .catch(error => { - log.error(error) + log.error('Error copying Hunspell dictionary: ', error) }) } @@ -143,7 +143,13 @@ class App { } } - const { startUpAction, defaultDirectoryToOpen } = preferences.getAll() + const { + startUpAction, + defaultDirectoryToOpen, + autoSwitchTheme, + theme + } = preferences.getAll() + if (startUpAction === 'folder' && defaultDirectoryToOpen) { const info = normalizeMarkdownPath(defaultDirectoryToOpen) if (info) { @@ -151,29 +157,32 @@ class App { } } + // Set initial native theme for theme in preferences. + const isDarkTheme = /dark/i.test(theme) + if (autoSwitchTheme === 0 && isDarkTheme !== nativeTheme.shouldUseDarkColors) { + selectTheme(nativeTheme.shouldUseDarkColors ? 'dark' : 'light') + nativeTheme.themeSource = nativeTheme.shouldUseDarkColors ? 'dark' : 'light' + } else { + nativeTheme.themeSource = isDarkTheme ? 'dark' : 'light' + } + + let isDarkMode = nativeTheme.shouldUseDarkColors + ipcMain.on('broadcast-preferences-changed', change => { + // Set Chromium's color for native elements after theme change. + if (change.theme) { + const isDarkTheme = /dark/i.test(change.theme) + if (isDarkMode !== isDarkTheme) { + isDarkMode = isDarkTheme + nativeTheme.themeSource = isDarkTheme ? 'dark' : 'light' + } else if (nativeTheme.themeSource === 'system') { + // Need to set dark or light theme because we set `system` to get the current system theme. + nativeTheme.themeSource = isDarkMode ? 'dark' : 'light' + } + } + }) + if (isOsx) { app.dock.setMenu(dockMenu) - - // Listen for system theme change and change Mark Text own `dark` and `light`. - // In macOS 10.14 Mojave, Apple introduced a new system-wide dark mode for - // all macOS computers. - systemPreferences.subscribeNotification( - 'AppleInterfaceThemeChangedNotification', - () => { - const preferences = this._accessor.preferences - const { theme } = preferences.getAll() - - // Application menu is automatically updated via preference manager. - if (systemPreferences.isDarkMode() && theme !== 'dark' && - theme !== 'material-dark' && theme !== 'one-dark') { - selectTheme('dark') - } - if (!systemPreferences.isDarkMode() && theme !== 'light' && - theme !== 'ulysses' && theme !== 'graphite') { - selectTheme('light') - } - } - ) } else if (isWindows) { app.setJumpList([{ type: 'recent' diff --git a/src/main/config.js b/src/main/config.js index f1f28771..90523f28 100644 --- a/src/main/config.js +++ b/src/main/config.js @@ -16,7 +16,7 @@ export const editorWinOptions = { zoomFactor: 1.0 } -export const defaultPreferenceWinOptions = { +export const preferencesWinOptions = { width: 950, height: 650, webPreferences: { diff --git a/src/main/dataCenter/index.js b/src/main/dataCenter/index.js index 9742fe38..4b59b7d9 100644 --- a/src/main/dataCenter/index.js +++ b/src/main/dataCenter/index.js @@ -71,7 +71,7 @@ class DataCenter extends EventEmitter { return Object.assign(data, encryptObj) } catch (err) { - log.error(err) + log.error('Failed to decrypt secure keys:', err) return data } } @@ -133,7 +133,7 @@ class DataCenter extends EventEmitter { try { return await keytar.setPassword(serviceName, key, value) } catch (err) { - log.error(err) + log.error('dataCenter::setItem:', err) } } else { return this.store.set(key, value) diff --git a/src/main/filesystem/watcher.js b/src/main/filesystem/watcher.js index fa5a3696..5d242a50 100644 --- a/src/main/filesystem/watcher.js +++ b/src/main/filesystem/watcher.js @@ -235,7 +235,7 @@ class Watcher { }) } } else { - log.error(error) + log.error('Error while watching files:', error) } }) diff --git a/src/main/index.js b/src/main/index.js index 19a588dd..60d39444 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -60,9 +60,7 @@ try { // Catch errors that may come from invalid configuration files like settings. const msgHint = err.message.includes('Config schema violation') ? 'This seems to be an issue with your configuration file(s). ' : '' - - log.error(`Loading Mark Text failed during initialization! ${msgHint}`) - log.error(err) + log.error(`Loading Mark Text failed during initialization! ${msgHint}`, err) const EXIT_ON_ERROR = !!process.env.MARKTEXT_EXIT_ON_ERROR const SHOW_ERROR_DIALOG = !process.env.MARKTEXT_ERROR_INTERACTION diff --git a/src/main/menu/actions/file.js b/src/main/menu/actions/file.js index 4eba8183..1dccfa3d 100644 --- a/src/main/menu/actions/file.js +++ b/src/main/menu/actions/file.js @@ -62,7 +62,7 @@ const handleResponseForExport = async (e, { type, content, pathname, title, page } win.webContents.send('AGANI::export-success', { type, filePath }) } catch (err) { - log.error(err) + log.error('Error while exporting:', err) const ERROR_MSG = err.message || `Error happened when export ${filePath}` win.webContents.send('AGANI::show-notification', { title: 'Export failure', @@ -80,19 +80,9 @@ const handleResponseForExport = async (e, { type, content, pathname, title, page const handleResponseForPrint = e => { const win = BrowserWindow.fromWebContents(e.sender) - - // See GH#749, Electron#16085 and Electron#17523. - dialog.showMessageBox(win, { - type: 'info', - buttons: ['OK'], - defaultId: 0, - noLink: true, - message: 'Printing doesn\'t work', - detail: 'Printing is disabled due to an Electron upstream issue. Please export the document as PDF and print the PDF file. We apologize for the inconvenience!' + win.webContents.print({ printBackground: true }, () => { + removePrintServiceFromWindow(win) }) - // win.webContents.print({ printBackground: true }, () => { - // removePrintServiceFromWindow(win) - // }) } const handleResponseForSave = async (e, { id, filename, markdown, pathname, options, defaultPath }) => { @@ -140,7 +130,7 @@ const handleResponseForSave = async (e, { id, filename, markdown, pathname, opti return id }) .catch(err => { - log.error(err) + log.error('Error while saving:', err) win.webContents.send('mt::tab-save-failure', id, err.message) }) } @@ -185,7 +175,7 @@ const openPandocFile = async (windowId, pathname) => { const data = await converter() ipcMain.emit('app-open-markdown-by-id', windowId, data) } catch (err) { - log.error(err) + log.error('Error while converting file:', err) } } @@ -216,7 +206,7 @@ ipcMain.on('mt::save-and-close-tabs', async (e, unsavedFiles) => { win.send('mt::force-close-tabs-by-id', tabIds) }) .catch(err => { - log.error(err.error) + log.error('Error while save all:', err.error) }) } else { const tabIds = unsavedFiles.map(f => f.id) @@ -262,7 +252,7 @@ ipcMain.on('AGANI::response-file-save-as', async (e, { id, filename, markdown, p } }) .catch(err => { - log.error(err) + log.error('Error while save as:', err) win.webContents.send('mt::tab-save-failure', id, err.message) }) } @@ -282,8 +272,7 @@ ipcMain.on('mt::close-window-confirm', async (e, unsavedFiles) => { ipcMain.emit('window-close-by-id', win.id) }) .catch(err => { - console.log(err) - log.error(err) + log.error('Error while saving before quit:', err) // Notify user about the problem. dialog.showMessageBox(win, { @@ -446,19 +435,9 @@ export const importFile = async win => { } export const print = win => { - if (!win) { - return + if (win) { + win.webContents.send('mt::show-export-dialog', 'print') } - // See GH#749, Electron#16085 and Electron#17523. - dialog.showMessageBox(win, { - type: 'info', - buttons: ['OK'], - defaultId: 0, - noLink: true, - message: 'Printing doesn\'t work', - detail: 'Printing is disabled due to an Electron upstream issue. Please export the document as PDF and print the PDF file. We apologize for the inconvenience!' - }) - // win.webContents.send('mt::show-export-dialog', 'print') } export const openFile = async win => { diff --git a/src/main/menu/actions/window.js b/src/main/menu/actions/window.js index 4874f605..01a0a077 100644 --- a/src/main/menu/actions/window.js +++ b/src/main/menu/actions/window.js @@ -9,13 +9,13 @@ export const toggleAlwaysOnTop = win => { export const zoomIn = win => { const { webContents } = win const zoom = webContents.getZoomFactor() - // WORKAROUND: Electron#16018 + // WORKAROUND: We need to set zoom on the browser window due to Electron#16018. webContents.send('mt::window-zoom', Math.min(2.0, zoom + 0.125)) } export const zoomOut = win => { const { webContents } = win const zoom = webContents.getZoomFactor() - // WORKAROUND: Electron#16018 + // WORKAROUND: We need to set zoom on the browser window due to Electron#16018. webContents.send('mt::window-zoom', Math.max(1.0, zoom - 0.125)) } diff --git a/src/main/menu/index.js b/src/main/menu/index.js index 0ac669fa..6f811c53 100644 --- a/src/main/menu/index.js +++ b/src/main/menu/index.js @@ -92,7 +92,7 @@ class AppMenu { } return recentDocuments } catch (err) { - log.error(err) + log.error('Error while read recently used documents:', err) return [] } } diff --git a/src/main/menu/templates/paragraph.js b/src/main/menu/templates/paragraph.js index d927de53..b6400a05 100644 --- a/src/main/menu/templates/paragraph.js +++ b/src/main/menu/templates/paragraph.js @@ -166,7 +166,7 @@ export default function (keybindings) { } }, { id: 'frontMatterMenuItem', - label: 'YAML Front Matter', + label: 'Front Matter', type: 'checkbox', accelerator: keybindings.getAccelerator('paragraphYAMLFrontMatter'), click (menuItem, browserWindow) { diff --git a/src/main/menu/templates/window.js b/src/main/menu/templates/window.js index cc0b5e38..4e69c566 100755 --- a/src/main/menu/templates/window.js +++ b/src/main/menu/templates/window.js @@ -17,19 +17,13 @@ export default function (keybindings) { toggleAlwaysOnTop(browserWindow) } }, { - // TODO: Disable due GH#1225. - visible: false, type: 'separator' }, { - // TODO: Disable due GH#1225. - visible: false, label: 'Zoom In', click (menuItem, browserWindow) { zoomIn(browserWindow) } }, { - // TODO: Disable due GH#1225. - visible: false, label: 'Zoom Out', click (menuItem, browserWindow) { zoomOut(browserWindow) diff --git a/src/main/platform/win32/registry.js b/src/main/platform/win32/registry.js deleted file mode 100755 index d027d751..00000000 --- a/src/main/platform/win32/registry.js +++ /dev/null @@ -1,34 +0,0 @@ -import { isWindows } from '../../config' - -let GetStringRegKey = null -if (isWindows) { - try { - GetStringRegKey = require('vscode-windows-registry').GetStringRegKey - } catch (e) { - // Ignore webpack build error on macOS and Linux. - } -} - -export const winHKEY = { - HKCU: 'HKEY_CURRENT_USER', - HKLM: 'HKEY_LOCAL_MACHINE', - HKCR: 'HKEY_CLASSES_ROOT', - HKU: 'HKEY_USERS', - HKCC: 'HKEY_CURRENT_CONFIG' -} - -/** - * Returns the registry key value. - * - * @param {winHKEY} hive The registry key - * @param {string} path The registry subkey - * @param {string} name The registry name - * @returns {string|null|undefined} The registry key value or null/undefined. - */ -export const getStringRegKey = (hive, path, name) => { - try { - return GetStringRegKey(hive, path, name) - } catch (e) { - return null - } -} diff --git a/src/main/preferences/index.js b/src/main/preferences/index.js index 48e4fa81..69f69687 100644 --- a/src/main/preferences/index.js +++ b/src/main/preferences/index.js @@ -3,24 +3,12 @@ import fs from 'fs' import path from 'path' import EventEmitter from 'events' import Store from 'electron-store' -import { BrowserWindow, ipcMain, systemPreferences } from 'electron' +import { BrowserWindow, ipcMain, nativeTheme } from 'electron' import log from 'electron-log' -import { isOsx, isWindows } from '../config' +import { isWindows } from '../config' import { hasSameKeys } from '../utils' -import { getStringRegKey, winHKEY } from '../platform/win32/registry.js' import schema from './schema' -const isDarkSystemMode = () => { - if (isOsx) { - return systemPreferences.isDarkMode() - } else if (isWindows) { - // NOTE: This key is a 32-Bit DWORD but converted to JS string! - const buf = getStringRegKey(winHKEY.HKCU, 'Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize', 'AppsUseLightTheme') - return buf === '' // zero (0) - } - return false -} - const PREFERENCES_FILE_NAME = 'preferences' class Preference extends EventEmitter { @@ -50,7 +38,9 @@ class Preference extends EventEmitter { let defaultSettings = null try { defaultSettings = fse.readJsonSync(this.staticPath) - if (isDarkSystemMode()) { + + // Set best theme on first application start. + if (nativeTheme.shouldUseDarkColors) { defaultSettings.theme = 'dark' } } catch (err) { diff --git a/src/main/preferences/schema.json b/src/main/preferences/schema.json index 4f026f6a..26e6cf69 100644 --- a/src/main/preferences/schema.json +++ b/src/main/preferences/schema.json @@ -242,11 +242,25 @@ "type": "boolean", "default": false }, + "footnote": { + "description": "Markdown-Enable pandoc's markdown extension footnote.", + "type": "boolean", + "default": false + }, "theme": { "description": "Theme--Select the theme used in Mark Text", "type": "string" }, + "autoSwitchTheme": { + "description": "Theme--Automatically adjust application theme according system.", + "default": 2, + "enum": [ + 0, + 1, + 2 + ] + }, "spellcheckerEnabled": { "description": "Spelling--Whether spell checking is enabled.", diff --git a/src/main/utils/imagePathAutoComplement.js b/src/main/utils/imagePathAutoComplement.js index e9a3d69b..235c86c6 100644 --- a/src/main/utils/imagePathAutoComplement.js +++ b/src/main/utils/imagePathAutoComplement.js @@ -46,8 +46,9 @@ const filesHandler = (files, directory, key) => { const rebuild = (directory) => { fs.readdir(directory, (err, files) => { - if (err) log.error(err) - else { + if (err) { + log.error('imagePathAutoComplement::rebuild:', err) + } else { filesHandler(files, directory) } }) diff --git a/src/main/windows/setting.js b/src/main/windows/setting.js index f0a27751..b9eb5554 100644 --- a/src/main/windows/setting.js +++ b/src/main/windows/setting.js @@ -3,7 +3,7 @@ import { BrowserWindow, ipcMain } from 'electron' import electronLocalshortcut from '@hfelix/electron-localshortcut' import BaseWindow, { WindowLifecycle, WindowType } from './base' import { centerWindowOptions } from './utils' -import { TITLE_BAR_HEIGHT, defaultPreferenceWinOptions, isLinux, isOsx } from '../config' +import { TITLE_BAR_HEIGHT, preferencesWinOptions, isLinux, isOsx, isWindows } from '../config' class SettingWindow extends BaseWindow { /** @@ -21,12 +21,18 @@ class SettingWindow extends BaseWindow { */ createWindow (options = {}) { const { menu: appMenu, env, keybindings, preferences } = this._accessor - const winOptions = Object.assign({}, defaultPreferenceWinOptions, options) + const winOptions = Object.assign({}, preferencesWinOptions, options) centerWindowOptions(winOptions) if (isLinux) { winOptions.icon = path.join(__static, 'logo-96px.png') } + // WORKAROUND: Electron has issues with different DPI per monitor when + // setting a fixed window size. + if (isWindows) { + winOptions.resizable = true + } + // Enable native or custom/frameless window and titlebar const { titleBarStyle, theme } = preferences.getAll() if (!isOsx) { diff --git a/src/muya/lib/assets/pngicon/footnote/1.png b/src/muya/lib/assets/pngicon/footnote/1.png new file mode 100755 index 00000000..379ca9a6 Binary files /dev/null and b/src/muya/lib/assets/pngicon/footnote/1.png differ diff --git a/src/muya/lib/assets/pngicon/footnote/2.png b/src/muya/lib/assets/pngicon/footnote/2.png new file mode 100755 index 00000000..bff14d4d Binary files /dev/null and b/src/muya/lib/assets/pngicon/footnote/2.png differ diff --git a/src/muya/lib/assets/pngicon/footnote/3.png b/src/muya/lib/assets/pngicon/footnote/3.png new file mode 100755 index 00000000..1b2b84aa Binary files /dev/null and b/src/muya/lib/assets/pngicon/footnote/3.png differ diff --git a/src/muya/lib/assets/pngicon/highlight/1.png b/src/muya/lib/assets/pngicon/highlight/1.png new file mode 100755 index 00000000..107c43f1 Binary files /dev/null and b/src/muya/lib/assets/pngicon/highlight/1.png differ diff --git a/src/muya/lib/assets/pngicon/highlight/2.png b/src/muya/lib/assets/pngicon/highlight/2.png new file mode 100755 index 00000000..eb36a37c Binary files /dev/null and b/src/muya/lib/assets/pngicon/highlight/2.png differ diff --git a/src/muya/lib/assets/pngicon/highlight/3.png b/src/muya/lib/assets/pngicon/highlight/3.png new file mode 100755 index 00000000..4d160226 Binary files /dev/null and b/src/muya/lib/assets/pngicon/highlight/3.png differ diff --git a/src/muya/lib/assets/pngicon/warning/2.png b/src/muya/lib/assets/pngicon/warning/2.png new file mode 100644 index 00000000..8d899f6f Binary files /dev/null and b/src/muya/lib/assets/pngicon/warning/2.png differ diff --git a/src/muya/lib/assets/styles/exportStyle.css b/src/muya/lib/assets/styles/exportStyle.css new file mode 100644 index 00000000..4d15756d --- /dev/null +++ b/src/muya/lib/assets/styles/exportStyle.css @@ -0,0 +1,15 @@ +.footnotes { + font-size: .85em; + opacity: .8; +} + +.footnotes li[role="doc-endnote"] { + position: relative; +} + +.footnotes .footnote-back { + position: absolute; + font-family: initial; + top: .2em; + right: 1em; +} diff --git a/src/muya/lib/assets/styles/index.css b/src/muya/lib/assets/styles/index.css index ec94dfe7..c9ab6d3b 100644 --- a/src/muya/lib/assets/styles/index.css +++ b/src/muya/lib/assets/styles/index.css @@ -180,6 +180,55 @@ figure[data-role="HTML"].ag-active .ag-html-preview { display: none; } +figure[data-role="FOOTNOTE"] { + position: relative; + background: var(--footnoteBgColor); + padding: 1.2em 2em .05em 1em; + font-size: .8em; + opacity: .8; +} + +figure[data-role="FOOTNOTE"] > p:first-of-type .ag-paragraph-content:empty::after { + content: 'Input the footnote definition...'; + color: var(--editorColor30); +} + +figure[data-role="FOOTNOTE"].ag-active::before { + content: attr(data-role); + text-transform: lowercase; + position: absolute; + top: .2em; + right: 1em; + color: var(--editorColor30); + font-size: 12px; +} + +figure[data-role="FOOTNOTE"] pre { + font-size: .8em; +} + +figure[data-role="FOOTNOTE"] .ag-footnote-input { + padding: 0 1em; + min-width: 80px; + position: absolute; + top: 0.2em; + left: 0; + font-size: 14px; + font-family: monospace; + font-weight: 600; + color: var(--editorColor); + background: transparent; + z-index: 1; +} + +figure[data-role="FOOTNOTE"] .ag-footnote-input::before { + content: '[^'; +} + +figure[data-role="FOOTNOTE"] .ag-footnote-input::after { + content: ']:'; +} + .ag-highlight { animation-name: highlight; animation-duration: .25s; @@ -1194,3 +1243,28 @@ figure:not(.ag-active) pre.ag-paragraph.line-numbers { top: .05em; } +.ag-inline-footnote-identifier { + background: var(--codeBlockBgColor); + padding: 0 0.4em; + border-radius: 3px; + font-size: .7em; + color: var(--editorColor80); +} + +.ag-inline-footnote-identifier a { + color: var(--editorColor); +} + +i.ag-footnote-backlink { + width: 20px; + height: 20px; + text-align: center; + line-height: 20px; + display: block; + position: absolute; + right: .5em; + bottom: .5em; + font-family: sans-serif; + cursor: pointer; + z-index: 100; +} diff --git a/src/muya/lib/config/index.js b/src/muya/lib/config/index.js index 3b2c1a97..fe291b6a 100644 --- a/src/muya/lib/config/index.js +++ b/src/muya/lib/config/index.js @@ -107,6 +107,7 @@ export const CLASS_OR_ID = genUpper2LowerKeyHash([ 'AG_INLINE_IMAGE_SELECTED', 'AG_INLINE_IMAGE_IS_EDIT', 'AG_INDENT_CODE', + 'AG_INLINE_FOOTNOTE_IDENTIFIER', 'AG_INLINE_RULE', 'AG_LANGUAGE', 'AG_LANGUAGE_INPUT', @@ -276,7 +277,8 @@ export const MUYA_DEFAULT_OPTION = { imagePathAutoComplete: () => [], // Markdown extensions - superSubScript: false + superSubScript: false, + footnote: false } // export const DIAGRAM_TEMPLATE = { diff --git a/src/muya/lib/contentState/backspaceCtrl.js b/src/muya/lib/contentState/backspaceCtrl.js index 3840f135..1d2dba1d 100644 --- a/src/muya/lib/contentState/backspaceCtrl.js +++ b/src/muya/lib/contentState/backspaceCtrl.js @@ -345,6 +345,29 @@ const backspaceCtrl = ContentState => { } if ( + block.type === 'span' && + block.functionType === 'paragraphContent' && + left === 0 && + preBlock && + preBlock.functionType === 'footnoteInput' + ) { + event.preventDefault() + event.stopPropagation() + if (!parent.nextSibling) { + const pBlock = this.createBlockP(block.text) + const figureBlock = this.closest(block, 'figure') + this.insertBefore(pBlock, figureBlock) + this.removeBlock(figureBlock) + const key = pBlock.children[0].key + const offset = 0 + this.cursor = { + start: { key, offset }, + end: { key, offset } + } + + this.partialRender() + } + } else if ( block.type === 'span' && block.functionType === 'codeContent' && left === 0 && @@ -492,7 +515,7 @@ const backspaceCtrl = ContentState => { // also need to remove the paragrah if (this.isOnlyChild(block) && block.type === 'span') { this.removeBlock(parent) - } else if (block.functionType !== 'languageInput') { + } else if (block.functionType !== 'languageInput' && block.functionType !== 'footnoteInput') { this.removeBlock(block) } @@ -500,10 +523,14 @@ const backspaceCtrl = ContentState => { start: { key, offset }, end: { key, offset } } - if (this.isCollapse()) { + let needRenderAll = false + + if (this.isCollapse() && preBlock.type === 'span' && preBlock.functionType === 'paragraphContent') { this.checkInlineUpdate(preBlock) + needRenderAll = true } - this.partialRender() + + needRenderAll ? this.render() : this.partialRender() } } } diff --git a/src/muya/lib/contentState/copyCutCtrl.js b/src/muya/lib/contentState/copyCutCtrl.js index 76106150..2395f114 100644 --- a/src/muya/lib/contentState/copyCutCtrl.js +++ b/src/muya/lib/contentState/copyCutCtrl.js @@ -191,7 +191,7 @@ const copyCutCtrl = ContentState => { } let htmlData = wrapper.innerHTML - const textData = this.htmlToMarkdown(htmlData) + const textData = escapeHtml(this.htmlToMarkdown(htmlData)) htmlData = marked(textData) return { html: htmlData, text: textData } diff --git a/src/muya/lib/contentState/enterCtrl.js b/src/muya/lib/contentState/enterCtrl.js index db084aa1..b326f320 100644 --- a/src/muya/lib/contentState/enterCtrl.js +++ b/src/muya/lib/contentState/enterCtrl.js @@ -1,6 +1,10 @@ import selection from '../selection' import { isOsx } from '../config' +/* eslint-disable no-useless-escape */ +const FOOTNOTE_REG = /^\[\^([^\^\[\]\s]+?)(? { const pairStr = text.substring(offset - 1, offset + 1) return /^(\{\}|\[\]|\(\)|><)$/.test(pairStr) @@ -226,6 +230,26 @@ const enterCtrl = ContentState => { return this.enterHandler(event) } + if ( + block.type === 'span' && + block.functionType === 'paragraphContent' && + !this.getParent(block).parent && + start.offset === text.length && + FOOTNOTE_REG.test(text) + ) { + event.preventDefault() + event.stopPropagation() + // Just to feet the `updateFootnote` API and add one white space. + block.text += ' ' + const key = block.key + const offset = block.text.length + this.cursor = { + start: { key, offset }, + end: { key, offset } + } + return this.updateFootnote(this.getParent(block), block) + } + // handle `shift + enter` insert `soft line break` or `hard line break` // only cursor in `line block` can create `soft line break` and `hard line break` // handle line in code block @@ -418,6 +442,7 @@ const enterCtrl = ContentState => { } this.insertAfter(newBlock, block) + break } case left === 0 && right === 0: { @@ -511,7 +536,14 @@ const enterCtrl = ContentState => { end: { key, offset } } - this.partialRender() + let needRenderAll = false + + if (this.isCollapse() && cursorBlock.type === 'p') { + this.checkInlineUpdate(cursorBlock.children[0]) + needRenderAll = true + } + + needRenderAll ? this.render() : this.partialRender() } } diff --git a/src/muya/lib/contentState/footnoteCtrl.js b/src/muya/lib/contentState/footnoteCtrl.js new file mode 100644 index 00000000..da9195ae --- /dev/null +++ b/src/muya/lib/contentState/footnoteCtrl.js @@ -0,0 +1,63 @@ +/* eslint-disable no-useless-escape */ +const FOOTNOTE_REG = /^\[\^([^\^\[\]\s]+?)(? { + ContentState.prototype.updateFootnote = function (block, line) { + const { start, end } = this.cursor + const { text } = line + const match = FOOTNOTE_REG.exec(text) + const footnoteIdentifer = match[1] + const sectionWrapper = this.createBlock('figure', { + functionType: 'footnote' + }) + const footnoteInput = this.createBlock('span', { + text: footnoteIdentifer, + functionType: 'footnoteInput' + }) + const pBlock = this.createBlockP(text.substring(match[0].length)) + this.appendChild(sectionWrapper, footnoteInput) + this.appendChild(sectionWrapper, pBlock) + this.insertBefore(sectionWrapper, block) + this.removeBlock(block) + + const { key } = pBlock.children[0] + this.cursor = { + start: { + key, + offset: Math.max(0, start.offset - footnoteIdentifer.length) + }, + end: { + key, + offset: Math.max(0, end.offset - footnoteIdentifer.length) + } + } + + if (this.isCollapse()) { + this.checkInlineUpdate(pBlock.children[0]) + } + + this.render() + return sectionWrapper + } + + ContentState.prototype.createFootnote = function (identifier) { + const { blocks } = this + const lastBlock = blocks[blocks.length - 1] + const newBlock = this.createBlockP(`[^${identifier}]: `) + this.insertAfter(newBlock, lastBlock) + const key = newBlock.children[0].key + const offset = newBlock.children[0].text.length + this.cursor = { + start: { key, offset }, + end: { key, offset } + } + const sectionWrapper = this.updateFootnote(newBlock, newBlock.children[0]) + const id = sectionWrapper.key + const footnoteEle = document.querySelector(`#${id}`) + if (footnoteEle) { + footnoteEle.scrollIntoView({ behavior: 'smooth' }) + } + } +} + +export default footnoteCtrl diff --git a/src/muya/lib/contentState/formatCtrl.js b/src/muya/lib/contentState/formatCtrl.js index 5027138d..1ab8e536 100644 --- a/src/muya/lib/contentState/formatCtrl.js +++ b/src/muya/lib/contentState/formatCtrl.js @@ -86,7 +86,12 @@ const clearFormat = (token, { start, end }) => { } const addFormat = (type, block, { start, end }) => { - if (block.type === 'pre') return false + if ( + block.type !== 'span' || + (block.type === 'span' && !/paragraphContent|cellConntent|atxLine/.test(block.functionType)) + ) { + return false + } switch (type) { case 'em': case 'del': diff --git a/src/muya/lib/contentState/imageCtrl.js b/src/muya/lib/contentState/imageCtrl.js index 7279bec0..7779f328 100644 --- a/src/muya/lib/contentState/imageCtrl.js +++ b/src/muya/lib/contentState/imageCtrl.js @@ -29,7 +29,7 @@ const imageCtrl = ContentState => { // Only encode URLs but not local paths or data URLs let imgUrl if (!/data:image/.test(src)) { - imgUrl = encodeURI(src) + imgUrl = encodeURI(src).replace(/#/g, encodeURIComponent('#')) } else { imgUrl = src } @@ -132,7 +132,7 @@ const imageCtrl = ContentState => { } imageText += '](' if (src) { - imageText += encodeURI(src) + imageText += encodeURI(src).replace(/#/g, encodeURIComponent('#')) } if (title) { imageText += ` "${title}"` @@ -177,11 +177,19 @@ const imageCtrl = ContentState => { this.selectedImage = imageInfo const { key } = imageInfo const block = this.getBlock(key) + const outMostBlock = this.findOutMostBlock(block) this.cursor = { start: { key, offset: imageInfo.token.range.end }, end: { key, offset: imageInfo.token.range.end } } - return this.singleRender(block, true) + // Fix #1568 + const { start } = this.prevCursor + const oldBlock = this.findOutMostBlock(this.getBlock(start.key)) + if (oldBlock.key !== outMostBlock.key) { + this.singleRender(oldBlock, false) + } + + return this.singleRender(outMostBlock, true) } } diff --git a/src/muya/lib/contentState/index.js b/src/muya/lib/contentState/index.js index 47ce4214..735dd93b 100644 --- a/src/muya/lib/contentState/index.js +++ b/src/muya/lib/contentState/index.js @@ -28,6 +28,7 @@ import emojiCtrl from './emojiCtrl' import imageCtrl from './imageCtrl' import linkCtrl from './linkCtrl' import dragDropCtrl from './dragDropCtrl' +import footnoteCtrl from './footnoteCtrl' import importMarkdown from '../utils/importMarkdown' import Cursor from '../selection/cursor' import escapeCharactersMap, { escapeCharacters } from '../parser/escapeCharacter' @@ -58,6 +59,7 @@ const prototypes = [ imageCtrl, linkCtrl, dragDropCtrl, + footnoteCtrl, importMarkdown ] diff --git a/src/muya/lib/contentState/updateCtrl.js b/src/muya/lib/contentState/updateCtrl.js index fadccfab..baedb065 100644 --- a/src/muya/lib/contentState/updateCtrl.js +++ b/src/muya/lib/contentState/updateCtrl.js @@ -10,6 +10,7 @@ const INLINE_UPDATE_FRAGMENTS = [ '^(?:[\\s\\S]+?)\\n {0,3}(\\={3,}|\\-{3,})(?= {1,}|$)', // Setext headings **match from beginning** '(?:^|\n) {0,3}(>).+', // Block quote '^( {4,})', // Indent code **match from beginning** + '^(\\[\\^[^\\^\\[\\]\\s]+?(? { if (/figure/.test(block.type)) { return false } - if (/cellContent|codeContent|languageInput/.test(block.functionType)) { + if (/cellContent|codeContent|languageInput|footnoteInput/.test(block.functionType)) { return false } @@ -89,8 +90,9 @@ const updateCtrl = ContentState => { const listItem = this.getParent(block) const [ match, bullet, tasklist, order, atxHeader, - setextHeader, blockquote, indentCode, hr + setextHeader, blockquote, indentCode, footnote, hr ] = text.match(INLINE_UPDATE_REG) || [] + const { footnote: isSupportFootnote } = this.muya.options switch (true) { case (!!hr && new Set(hr.split('').filter(i => /\S/.test(i))).size === 1): @@ -118,6 +120,9 @@ const updateCtrl = ContentState => { case !!indentCode: return this.updateIndentCode(block, line) + case !!footnote && block.type === 'p' && !block.parent && isSupportFootnote: + return this.updateFootnote(block, line) + case !match: default: return this.updateToParagraph(block, line) diff --git a/src/muya/lib/eventHandler/clickEvent.js b/src/muya/lib/eventHandler/clickEvent.js index 1014b1f8..157f2a49 100644 --- a/src/muya/lib/eventHandler/clickEvent.js +++ b/src/muya/lib/eventHandler/clickEvent.js @@ -101,6 +101,7 @@ class ClickEvent { const rubyRender = target.closest(`.${CLASS_OR_ID.AG_RUBY_RENDER}`) const imageWrapper = target.closest(`.${CLASS_OR_ID.AG_INLINE_IMAGE}`) const codeCopy = target.closest('.ag-code-copy') + const footnoteBackLink = target.closest('.ag-footnote-backlink') const imageDelete = target.closest('.ag-image-icon-delete') || target.closest('.ag-image-icon-close') const mathText = mathRender && mathRender.previousElementSibling const rubyText = rubyRender && rubyRender.previousElementSibling @@ -131,6 +132,20 @@ class ClickEvent { return contentState.deleteImage(imageInfo) } + if (footnoteBackLink) { + event.preventDefault() + event.stopPropagation() + const figure = event.target.closest('figure') + const identifier = figure.querySelector('span.ag-footnote-input').textContent + if (identifier) { + const footnoteIdentifier = document.querySelector(`#noteref-${identifier}`) + if (footnoteIdentifier) { + footnoteIdentifier.scrollIntoView({ behavior: 'smooth' }) + } + } + return + } + // Handle image click, to select the current image if (target.tagName === 'IMG' && imageWrapper) { // Handle select image diff --git a/src/muya/lib/eventHandler/mouseEvent.js b/src/muya/lib/eventHandler/mouseEvent.js index 3aed62b7..ca7fff02 100644 --- a/src/muya/lib/eventHandler/mouseEvent.js +++ b/src/muya/lib/eventHandler/mouseEvent.js @@ -1,4 +1,5 @@ import { getLinkInfo } from '../utils/getLinkInfo' +import { collectFootnotes } from '../utils' class MouseEvent { constructor (muya) { @@ -12,30 +13,69 @@ class MouseEvent { const handler = event => { const target = event.target const parent = target.parentNode - const { hideLinkPopup } = this.muya.options - if (!hideLinkPopup && parent && parent.tagName === 'A' && parent.classList.contains('ag-inline-rule')) { - const rect = parent.getBoundingClientRect() - const reference = { - getBoundingClientRect () { - return rect - } + const preSibling = target.previousElementSibling + const parentPreSibling = parent ? parent.previousElementSibling : null + const { hideLinkPopup, footnote } = this.muya.options + const rect = parent.getBoundingClientRect() + const reference = { + getBoundingClientRect () { + return rect } + } + if ( + !hideLinkPopup && + parent && + parent.tagName === 'A' && + parent.classList.contains('ag-inline-rule') && + parentPreSibling && + parentPreSibling.classList.contains('ag-hide') + ) { eventCenter.dispatch('muya-link-tools', { reference, linkInfo: getLinkInfo(parent) }) } + + if ( + footnote && + parent && + parent.tagName === 'SUP' && + parent.classList.contains('ag-inline-footnote-identifier') && + preSibling && + preSibling.classList.contains('ag-hide') + ) { + const identifier = target.textContent + eventCenter.dispatch('muya-footnote-tool', { + reference, + identifier, + footnotes: collectFootnotes(this.muya.contentState.blocks) + }) + } } const leaveHandler = event => { const target = event.target const parent = target.parentNode - + const preSibling = target.previousElementSibling + const { footnote } = this.muya.options if (parent && parent.tagName === 'A' && parent.classList.contains('ag-inline-rule')) { eventCenter.dispatch('muya-link-tools', { reference: null }) } + + if ( + footnote && + parent && + parent.tagName === 'SUP' && + parent.classList.contains('ag-inline-footnote-identifier') && + preSibling && + preSibling.classList.contains('ag-hide') + ) { + eventCenter.dispatch('muya-footnote-tool', { + reference: null + }) + } } eventCenter.attachDOMEvent(container, 'mouseover', handler) diff --git a/src/muya/lib/parser/index.js b/src/muya/lib/parser/index.js index ec7dc9a9..d1e48033 100644 --- a/src/muya/lib/parser/index.js +++ b/src/muya/lib/parser/index.js @@ -32,12 +32,12 @@ const correctUrl = token => { } } -const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => { +const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels, options) => { const originSrc = src const tokens = [] let pending = '' let pendingStartPos = pos - + const { superSubScript, footnote } = options const pushPending = () => { if (pending) { tokens.push({ @@ -151,7 +151,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => { range, marker, parent: tokens, - children: tokenizerFac(to[2], undefined, inlineRules, pos + to[1].length, false, labels), + children: tokenizerFac(to[2], undefined, inlineRules, pos + to[1].length, false, labels, options), backlash: to[3] }) src = src.substring(to[0].length) @@ -192,7 +192,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => { range, marker, parent: tokens, - children: tokenizerFac(to[2], undefined, inlineRules, pos + to[1].length, false, labels), + children: tokenizerFac(to[2], undefined, inlineRules, pos + to[1].length, false, labels, options), backlash: to[3] }) } @@ -203,7 +203,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => { } if (inChunk) continue // superscript and subscript - if (inlineRules.superscript && inlineRules.subscript) { + if (superSubScript) { const superSubTo = inlineRules.superscript.exec(src) || inlineRules.subscript.exec(src) if (superSubTo) { pushPending() @@ -223,6 +223,28 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => { continue } } + + // footnote identifier + if (pos !== 0 && footnote) { + const footnoteTo = inlineRules.footnote_identifier.exec(src) + if (footnoteTo) { + pushPending() + tokens.push({ + type: 'footnote_identifier', + raw: footnoteTo[0], + marker: footnoteTo[1], + range: { + start: pos, + end: pos + footnoteTo[0].length + }, + parent: tokens, + content: footnoteTo[2] + }) + src = src.substring(footnoteTo[0].length) + pos = pos + footnoteTo[0].length + continue + } + } // image const imageTo = inlineRules.image.exec(src) correctUrl(imageTo) @@ -276,7 +298,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => { start: pos, end: pos + linkTo[0].length }, - children: tokenizerFac(linkTo[2], undefined, inlineRules, pos + linkTo[1].length, false, labels), + children: tokenizerFac(linkTo[2], undefined, inlineRules, pos + linkTo[1].length, false, labels, options), backlash: { first: linkTo[3], second: linkTo[5] @@ -306,7 +328,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => { start: pos, end: pos + rLinkTo[0].length }, - children: tokenizerFac(rLinkTo[1], undefined, inlineRules, pos + 1, false, labels) + children: tokenizerFac(rLinkTo[1], undefined, inlineRules, pos + 1, false, labels, options) }) src = src.substring(rLinkTo[0].length) @@ -442,7 +464,7 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => { parent: tokens, attrs, content: htmlTo[4], - children: htmlTo[4] ? tokenizerFac(htmlTo[4], undefined, inlineRules, pos + htmlTo[2].length, false, labels) : '', + children: htmlTo[4] ? tokenizerFac(htmlTo[4], undefined, inlineRules, pos + htmlTo[2].length, false, labels, options) : '', range: { start: pos, end: pos + len @@ -530,16 +552,8 @@ export const tokenizer = (src, { labels = new Map(), options = {} } = {}) => { - const { superSubScript } = options - - if (superSubScript) { - inlineRules.superscript = inlineExtensionRules.superscript - inlineRules.subscript = inlineExtensionRules.subscript - } else { - delete inlineRules.superscript - delete inlineRules.subscript - } - const tokens = tokenizerFac(src, hasBeginRules ? beginRules : null, inlineRules, 0, true, labels) + const rules = Object.assign({}, inlineRules, inlineExtensionRules) + const tokens = tokenizerFac(src, hasBeginRules ? beginRules : null, rules, 0, true, labels, options) const postTokenizer = tokens => { for (const token of tokens) { diff --git a/src/muya/lib/parser/marked/blockRules.js b/src/muya/lib/parser/marked/blockRules.js index 1b5d1033..99774b2b 100644 --- a/src/muya/lib/parser/marked/blockRules.js +++ b/src/muya/lib/parser/marked/blockRules.js @@ -35,7 +35,8 @@ export const block = { // extra frontmatter: /^(?:(?:---\n([\s\S]+?)---)|(?:\+\+\+\n([\s\S]+?)\+\+\+)|(?:;;;\n([\s\S]+?);;;)|(?:\{\n([\s\S]+?)\}))(?:\n{2,}|\n{1,2}$)/, - multiplemath: /^\$\$\n([\s\S]+?)\n\$\$(?:\n+|$)/ + multiplemath: /^\$\$\n([\s\S]+?)\n\$\$(?:\n+|$)/, + footnote: /^\[\^([^\^\[\]\s]+?)\]:[\s\S]+?(?=\n *\n {0,3}[^ ]+|$)/ } block._label = /(?!\s*\])(?:\\[\[\]]|[^\[\]])+/ diff --git a/src/muya/lib/parser/marked/inlineLexer.js b/src/muya/lib/parser/marked/inlineLexer.js index 359461f3..6349c8c7 100644 --- a/src/muya/lib/parser/marked/inlineLexer.js +++ b/src/muya/lib/parser/marked/inlineLexer.js @@ -1,16 +1,17 @@ import Renderer from './renderer' import { normal, breaks, gfm, pedantic } from './inlineRules' import defaultOptions from './options' -import { escape, findClosingBracket } from './utils' +import { escape, findClosingBracket, getUniqueId } from './utils' import { validateEmphasize, lowerPriority } from '../utils' /** * Inline Lexer & Compiler */ -function InlineLexer (links, options) { +function InlineLexer (links, footnotes, options) { this.options = options || defaultOptions this.links = links + this.footnotes = footnotes this.rules = normal this.renderer = this.options.renderer || new Renderer() this.renderer.options = this.options @@ -49,7 +50,7 @@ function InlineLexer (links, options) { InlineLexer.prototype.output = function (src) { // src = src // .replace(/\u00a0/g, ' ') - const { disableInline, emoji, math, superSubScript } = this.options + const { disableInline, emoji, math, superSubScript, footnote } = this.options if (disableInline) { return escape(src) } @@ -73,6 +74,19 @@ InlineLexer.prototype.output = function (src) { continue } + // footnote identifier + if (footnote) { + cap = this.rules.footnoteIdentifier.exec(src) + if (cap) { + src = src.substring(cap[0].length) + lastChar = cap[0].charAt(cap[0].length - 1) + const identifier = cap[1] + const footnoteInfo = this.footnotes[identifier] || {} + footnoteInfo.footnoteIdentifierId = getUniqueId() + out += this.renderer.footnoteIdentifier(identifier, footnoteInfo) + } + } + // tag cap = this.rules.tag.exec(src) if (cap) { diff --git a/src/muya/lib/parser/marked/inlineRules.js b/src/muya/lib/parser/marked/inlineRules.js index 8d0f6330..5eb11530 100644 --- a/src/muya/lib/parser/marked/inlineRules.js +++ b/src/muya/lib/parser/marked/inlineRules.js @@ -29,7 +29,7 @@ const inline = { // ------------------------ // patched - // allow inline math "$" and superscript ("?=[\\ 1 @@ -367,7 +425,7 @@ Lexer.prototype.token = function (src, top) { const isOrderedListItem = /\d/.test(bull) this.tokens.push({ - checked: checked, + checked, listItemType: bull.length > 1 ? 'order' : (isTaskList ? 'task' : 'bullet'), bulletMarkerOrDelimiter: isOrderedListItem ? bull.slice(-1) : bull.charAt(0), type: loose ? 'loose_item_start' : 'list_item_start' @@ -534,8 +592,6 @@ Lexer.prototype.token = function (src, top) { throw new Error('Infinite loop on byte: ' + src.charCodeAt(0)) } } - - return this.tokens } export default Lexer diff --git a/src/muya/lib/parser/marked/options.js b/src/muya/lib/parser/marked/options.js index e4631026..3d3a8158 100644 --- a/src/muya/lib/parser/marked/options.js +++ b/src/muya/lib/parser/marked/options.js @@ -28,5 +28,6 @@ export default { emoji: true, math: true, frontMatter: true, - superSubScript: false + superSubScript: false, + footnote: false } diff --git a/src/muya/lib/parser/marked/parser.js b/src/muya/lib/parser/marked/parser.js index 33dcbeb8..f4084fef 100644 --- a/src/muya/lib/parser/marked/parser.js +++ b/src/muya/lib/parser/marked/parser.js @@ -11,6 +11,8 @@ import defaultOptions from './options' function Parser (options) { this.tokens = [] this.token = null + this.footnotes = null + this.footnoteIdentifier = '' this.options = options || defaultOptions this.options.renderer = this.options.renderer || new Renderer() this.renderer = this.options.renderer @@ -23,14 +25,15 @@ function Parser (options) { */ Parser.prototype.parse = function (src) { - this.inline = new InlineLexer(src.links, this.options) + this.inline = new InlineLexer(src.links, src.footnotes, this.options) // use an InlineLexer with a TextRenderer to extract pure text this.inlineText = new InlineLexer( src.links, + src.footnotes, Object.assign({}, this.options, { renderer: new TextRenderer() }) ) this.tokens = src.reverse() - + this.footnotes = src.footnotes let out = '' while (this.next()) { out += this.tok() @@ -148,6 +151,27 @@ Parser.prototype.tok = function () { return this.renderer.blockquote(body) } + // All the tokens will be footnotes if it after a footnote_start token. Because we put all footnote token at the end. + case 'footnote_start': { + let body = '' + let itemBody = '' + this.footnoteIdentifier = this.token.identifier + while (this.next()) { + if (this.token.type === 'footnote_end') { + const footnoteInfo = this.footnotes[this.footnoteIdentifier] + body += this.renderer.footnoteItem(itemBody, footnoteInfo) + this.footnoteIdentifier = '' + itemBody = '' + } else if (this.token.type === 'footnote_start') { + this.footnoteIdentifier = this.token.identifier + itemBody = '' + } else { + itemBody += this.tok() + } + } + + return this.renderer.footnote(body) + } case 'list_start': { let body = '' let taskList = false diff --git a/src/muya/lib/parser/marked/renderer.js b/src/muya/lib/parser/marked/renderer.js index 451e30db..b25730dc 100644 --- a/src/muya/lib/parser/marked/renderer.js +++ b/src/muya/lib/parser/marked/renderer.js @@ -44,6 +44,18 @@ Renderer.prototype.script = function (content, marker) { return `<${tagName}>${content}` } +Renderer.prototype.footnoteIdentifier = function (identifier, { footnoteId, footnoteIdentifierId, order }) { + return `${order || identifier}` +} + +Renderer.prototype.footnote = function (footnote) { + return '
\n
\n
    \n' + footnote + '
\n
\n' +} + +Renderer.prototype.footnoteItem = function (content, { footnoteId, footnoteIdentifierId }) { + return `
  • ${content}↩︎
  • ` +} + Renderer.prototype.code = function (code, infostring, escaped, codeBlockStyle) { const lang = (infostring || '').match(/\S*/)[0] if (this.options.highlight) { diff --git a/src/muya/lib/parser/marked/utils.js b/src/muya/lib/parser/marked/utils.js index 74deac29..4a146bd3 100644 --- a/src/muya/lib/parser/marked/utils.js +++ b/src/muya/lib/parser/marked/utils.js @@ -2,6 +2,10 @@ * Helpers */ +let uniqueIdCounter = 0 + +export const getUniqueId = () => ++uniqueIdCounter + export const escape = function escape (html, encode) { if (encode) { if (escape.escapeTest.test(html)) { diff --git a/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js b/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js index 30d56733..f17a19d7 100644 --- a/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js +++ b/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js @@ -1,5 +1,6 @@ import { CLASS_OR_ID } from '../../../config' import { renderTableTools } from './renderToolBar' +import { footnoteJumpIcon } from './renderFootnoteJump' import { renderEditIcon } from './renderContainerEditIcon' import renderLineNumberRows from './renderLineNumber' import renderCopyButton from './renderCopyButton' @@ -138,10 +139,12 @@ export default function renderContainerBlock (parent, block, activeBlocks, match } else if (type === 'figure') { if (functionType) { Object.assign(data.dataset, { role: functionType.toUpperCase() }) - if (functionType === 'table') { + if (functionType === 'table' && activeBlocks[0] && activeBlocks[0].functionType === 'cellContent') { children.unshift(renderTableTools(activeBlocks)) - } else { + } else if (functionType !== 'footnote') { children.unshift(renderEditIcon()) + } else { + children.push(footnoteJumpIcon()) } } diff --git a/src/muya/lib/parser/render/renderBlock/renderFootnoteJump.js b/src/muya/lib/parser/render/renderBlock/renderFootnoteJump.js new file mode 100644 index 00000000..f3830b2e --- /dev/null +++ b/src/muya/lib/parser/render/renderBlock/renderFootnoteJump.js @@ -0,0 +1,5 @@ +import { h } from '../snabbdom' + +export const footnoteJumpIcon = () => { + return h('i.ag-footnote-backlink', '↩︎') +} diff --git a/src/muya/lib/parser/render/renderBlock/renderIcon.js b/src/muya/lib/parser/render/renderBlock/renderIcon.js index 761e1e43..bd9ae3e0 100644 --- a/src/muya/lib/parser/render/renderBlock/renderIcon.js +++ b/src/muya/lib/parser/render/renderBlock/renderIcon.js @@ -21,6 +21,7 @@ import flowchartIcon from '../../../assets/pngicon/flowchart/2.png' import sequenceIcon from '../../../assets/pngicon/sequence/2.png' import mermaidIcon from '../../../assets/pngicon/mermaid/2.png' import vegaIcon from '../../../assets/pngicon/chart/2.png' +import footnoteIcon from '../../../assets/pngicon/footnote/2.png' const FUNCTION_TYPE_HASH = { mermaid: mermaidIcon, @@ -32,7 +33,8 @@ const FUNCTION_TYPE_HASH = { multiplemath: mathblockIcon, fencecode: codeIcon, indentcode: codeIcon, - frontmatter: frontMatterIcon + frontmatter: frontMatterIcon, + footnote: footnoteIcon } export default function renderIcon (block) { diff --git a/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js b/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js index 54b493f8..58f51774 100644 --- a/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js +++ b/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js @@ -101,7 +101,8 @@ export default function renderLeafBlock (parent, block, activeBlocks, matches, u functionType !== 'codeContent' && functionType !== 'languageInput' ) { - const hasBeginRules = type === 'span' + const hasBeginRules = /paragraphContent|atxLine/.test(functionType) + tokens = tokenizer(text, { highlights, hasBeginRules, @@ -247,6 +248,8 @@ export default function renderLeafBlock (parent, block, activeBlocks, matches, u } else if (type === 'span' && functionType === 'languageInput') { const html = getHighlightHtml(text, highlights) children = htmlToVNode(html) + } else if (type === 'span' && functionType === 'footnoteInput') { + Object.assign(data.attrs, { spellcheck: 'false' }) } if (!block.parent) { diff --git a/src/muya/lib/parser/render/renderInlines/footnoteIdentifier.js b/src/muya/lib/parser/render/renderInlines/footnoteIdentifier.js new file mode 100644 index 00000000..51f6ccab --- /dev/null +++ b/src/muya/lib/parser/render/renderInlines/footnoteIdentifier.js @@ -0,0 +1,23 @@ +import { CLASS_OR_ID } from '../../../config' + +export default function footnoteIdentifier (h, cursor, block, token, outerClass) { + const className = this.getClassName(outerClass, block, token, cursor) + const { marker } = token + const { start, end } = token.range + + const startMarker = this.highlight(h, block, start, start + marker.length, token) + const endMarker = this.highlight(h, block, end - 1, end, token) + const content = this.highlight(h, block, start + marker.length, end - 1, token) + + return [ + h(`sup#noteref-${token.content}.${CLASS_OR_ID.AG_INLINE_FOOTNOTE_IDENTIFIER}.${CLASS_OR_ID.AG_INLINE_RULE}`, [ + h(`span.${className}.${CLASS_OR_ID.AG_REMOVE}`, startMarker), + h('a', { + attrs: { + spellcheck: 'false' + } + }, content), + h(`span.${className}.${CLASS_OR_ID.AG_REMOVE}`, endMarker) + ]) + ] +} diff --git a/src/muya/lib/parser/render/renderInlines/index.js b/src/muya/lib/parser/render/renderInlines/index.js index e06e46ad..e266a58a 100644 --- a/src/muya/lib/parser/render/renderInlines/index.js +++ b/src/muya/lib/parser/render/renderInlines/index.js @@ -28,6 +28,7 @@ import htmlRuby from './htmlRuby' import referenceLink from './referenceLink' import referenceImage from './referenceImage' import superSubScript from './superSubScript' +import footnoteIdentifier from './footnoteIdentifier' export default { backlashInToken, @@ -59,5 +60,6 @@ export default { htmlRuby, referenceLink, referenceImage, - superSubScript + superSubScript, + footnoteIdentifier } diff --git a/src/muya/lib/parser/rules.js b/src/muya/lib/parser/rules.js index 3f2b761b..070a817f 100644 --- a/src/muya/lib/parser/rules.js +++ b/src/muya/lib/parser/rules.js @@ -41,6 +41,7 @@ export const inlineRules = { export const inlineExtensionRules = { // This is not the best regexp, because it not support `2^2\\^`. superscript: /^(\^)((?:[^\^\s]|(?<=\\)\1|(?<=\\) )+?)(? div { + display: flex; + height: 35px; + align-items: center; + color: var(--editorColor); + font-size: 12px; + padding: 0 10px; +} + +.ag-footnote-tool .text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; +} + +.ag-footnote-tool .btn { + width: 40px; + display: inline-block; + cursor: pointer; +} + +.ag-footnote-tool .icon-wrapper { + width: 14px; + height: 14px; + margin-right: 5px; + position: relative; +} + +.ag-footnote-tool .icon-wrapper i.icon { + display: inline-block; + position: absolute; + top: 0; + height: 100%; + width: 100%; + overflow: hidden; + color: var(--iconColor); + transition: all .25s ease-in-out; +} + +.ag-footnote-tool .icon-wrapper i.icon > i[class^=icon-] { + display: inline-block; + width: 100%; + height: 100%; + filter: drop-shadow(14px 0 currentColor); + position: relative; + left: -14px; +} diff --git a/src/muya/lib/ui/footnoteTool/index.js b/src/muya/lib/ui/footnoteTool/index.js new file mode 100644 index 00000000..8f19a83f --- /dev/null +++ b/src/muya/lib/ui/footnoteTool/index.js @@ -0,0 +1,148 @@ +import BaseFloat from '../baseFloat' +import { patch, h } from '../../parser/render/snabbdom' +import WarningIcon from '../../assets/pngicon/warning/2.png' + +import './index.css' + +const getFootnoteText = block => { + let text = '' + const travel = block => { + if (block.children.length === 0 && block.text) { + text += block.text + } else if (block.children.length) { + for (const b of block.children) { + travel(b) + } + } + } + + const blocks = block.children.slice(1) + for (const b of blocks) { + travel(b) + } + + return text +} + +const defaultOptions = { + placement: 'bottom', + modifiers: { + offset: { + offset: '0, 5' + } + }, + showArrow: false +} + +class LinkTools extends BaseFloat { + static pluginName = 'footnoteTool' + + constructor (muya, options = {}) { + const name = 'ag-footnote-tool' + const opts = Object.assign({}, defaultOptions, options) + super(muya, name, opts) + this.oldVnode = null + this.identifier = null + this.footnotes = null + this.options = opts + this.hideTimer = null + const toolContainer = this.toolContainer = document.createElement('div') + this.container.appendChild(toolContainer) + this.floatBox.classList.add('ag-footnote-tool-container') + this.listen() + } + + listen () { + const { eventCenter } = this.muya + super.listen() + eventCenter.subscribe('muya-footnote-tool', ({ reference, identifier, footnotes }) => { + if (reference) { + this.footnotes = footnotes + this.identifier = identifier + setTimeout(() => { + this.show(reference) + this.render() + }, 0) + } else { + if (this.hideTimer) { + clearTimeout(this.hideTimer) + } + this.hideTimer = setTimeout(() => { + this.hide() + }, 500) + } + }) + + const mouseOverHandler = () => { + if (this.hideTimer) { + clearTimeout(this.hideTimer) + } + } + + const mouseOutHandler = () => { + this.hide() + } + + eventCenter.attachDOMEvent(this.container, 'mouseover', mouseOverHandler) + eventCenter.attachDOMEvent(this.container, 'mouseleave', mouseOutHandler) + } + + render () { + const { oldVnode, toolContainer, identifier, footnotes } = this + const hasFootnote = footnotes.has(identifier) + const iconWrapperSelector = 'div.icon-wrapper' + const icon = h('i.icon', h('i.icon-inner', { + style: { + background: `url(${WarningIcon}) no-repeat`, + 'background-size': '100%' + } + }, '')) + const iconWrapper = h(iconWrapperSelector, icon) + let text = 'Can\'t find footnote with syntax [^abc]:' + if (hasFootnote) { + const footnoteBlock = footnotes.get(identifier) + + text = getFootnoteText(footnoteBlock) + if (!text) { + text = 'Input the footnote definition...' + } + } + const textNode = h('span.text', text) + const button = h('a.btn', { + on: { + click: event => { + this.buttonClick(event, hasFootnote) + } + } + }, hasFootnote ? 'Go to' : 'Create') + const children = [textNode, button] + if (!hasFootnote) { + children.unshift(iconWrapper) + } + const vnode = h('div', children) + + if (oldVnode) { + patch(oldVnode, vnode) + } else { + patch(toolContainer, vnode) + } + this.oldVnode = vnode + } + + buttonClick (event, hasFootnote) { + event.preventDefault() + event.stopPropagation() + const { identifier, footnotes } = this + if (hasFootnote) { + const block = footnotes.get(identifier) + const key = block.key + const ele = document.querySelector(`#${key}`) + ele.scrollIntoView({ behavior: 'smooth' }) + } else { + this.muya.contentState.createFootnote(identifier) + } + return this.hide() + } +} + +export default LinkTools diff --git a/src/muya/lib/ui/formatPicker/config.js b/src/muya/lib/ui/formatPicker/config.js index 34db3463..73aa302d 100644 --- a/src/muya/lib/ui/formatPicker/config.js +++ b/src/muya/lib/ui/formatPicker/config.js @@ -7,6 +7,7 @@ import imageIcon from '../../assets/pngicon/format_image/2.png' import linkIcon from '../../assets/pngicon/format_link/2.png' import strikeIcon from '../../assets/pngicon/format_strike/2.png' import mathIcon from '../../assets/pngicon/format_math/2.png' +import highlightIcon from '../../assets/pngicon/highlight/2.png' import clearIcon from '../../assets/pngicon/format_clear/2.png' const COMMAND_KEY = isOsx ? '⌘' : '⌃' @@ -32,6 +33,11 @@ const icons = [ tooltip: 'Strikethrough', shortcut: `${COMMAND_KEY}+D`, icon: strikeIcon + }, { + type: 'mark', + tooltip: 'Highlight', + shortcut: `⇧+${COMMAND_KEY}+H`, + icon: highlightIcon }, { type: 'inline_code', tooltip: 'Inline Code', diff --git a/src/muya/lib/ui/formatPicker/index.css b/src/muya/lib/ui/formatPicker/index.css index cbbf1590..62660956 100644 --- a/src/muya/lib/ui/formatPicker/index.css +++ b/src/muya/lib/ui/formatPicker/index.css @@ -49,8 +49,8 @@ .ag-format-picker li.item .icon-wrapper { display: flex; - width: 14px; - height: 14px; + width: 16px; + height: 16px; } .ag-format-picker li.item .icon-wrapper i.icon { @@ -67,9 +67,9 @@ display: inline-block; width: 100%; height: 100%; - filter: drop-shadow(14px 0 currentColor); + filter: drop-shadow(16px 0 currentColor); position: relative; - left: -14px; + left: -16px; } .ag-format-picker li.item.active .icon-wrapper i.icon { diff --git a/src/muya/lib/ui/frontMenu/index.css b/src/muya/lib/ui/frontMenu/index.css index c8d8c8a1..28b07ae0 100644 --- a/src/muya/lib/ui/frontMenu/index.css +++ b/src/muya/lib/ui/frontMenu/index.css @@ -33,16 +33,16 @@ margin-left: 10px; margin-right: 8px; display: flex; - width: 14px; - height: 14px; + width: 16px; + height: 16px; color: var(--iconColor); } .ag-front-menu li.item .icon-wrapper i.icon { display: flex; position: relative; - height: 14px; - width: 14px; + height: 16px; + width: 16px; overflow: hidden; color: var(--iconColor); transition: all .25s ease-in-out; @@ -52,9 +52,9 @@ display: inline-block; width: 100%; height: 100%; - filter: drop-shadow(14px 0 currentColor); + filter: drop-shadow(16px 0 currentColor); position: relative; - left: -14px; + left: -16px; } .ag-front-menu > ul li > span { diff --git a/src/muya/lib/ui/imageSelector/index.css b/src/muya/lib/ui/imageSelector/index.css index f73df562..d3afbaf9 100644 --- a/src/muya/lib/ui/imageSelector/index.css +++ b/src/muya/lib/ui/imageSelector/index.css @@ -108,6 +108,7 @@ text-align: center; display: block; color: var(--editorColor30); + user-select: none; } .ag-image-selector span.description a { @@ -154,6 +155,7 @@ text-align: center; font-size: 14px; color: var(--editorColor); + user-select: none; } .ag-image-selector .more { @@ -161,6 +163,7 @@ color: var(--editorColor); text-align: center; margin-bottom: 20px; + user-select: none; } .ag-image-selector .photo { diff --git a/src/muya/lib/ui/imageSelector/index.js b/src/muya/lib/ui/imageSelector/index.js index 5e1e0229..e11f9592 100644 --- a/src/muya/lib/ui/imageSelector/index.js +++ b/src/muya/lib/ui/imageSelector/index.js @@ -12,7 +12,7 @@ class ImageSelector extends BaseFloat { constructor (muya, options) { const name = 'ag-image-selector' - const { accessKey } = options + const { unsplashAccessKey } = options options = Object.assign(options, { placement: 'bottom-center', modifiers: { @@ -26,9 +26,13 @@ class ImageSelector extends BaseFloat { this.renderArray = [] this.oldVnode = null this.imageInfo = null - this.unsplash = new Unsplash({ - accessKey - }) + if (!unsplashAccessKey) { + this.unsplash = null + } else { + this.unsplash = new Unsplash({ + accessKey: unsplashAccessKey + }) + } this.photoList = [] this.loading = false this.tab = 'link' // select or link @@ -56,22 +60,27 @@ class ImageSelector extends BaseFloat { } Object.assign(this.state, imageInfo.token.attrs) - // load latest unsplash photos. - this.loading = true - this.unsplash.photos.listPhotos(1, 40, 'latest') - .then(toJson) - .then(json => { - this.loading = false - if (Array.isArray(json)) { - this.photoList = json - if (this.tab === 'unsplash') { - this.render() + + if (this.unsplash) { + // Load latest unsplash photos. + this.loading = true + this.unsplash.photos.listPhotos(1, 40, 'latest') + .then(toJson) + .then(json => { + this.loading = false + if (Array.isArray(json)) { + this.photoList = json + if (this.tab === 'unsplash') { + this.render() + } } - } - }) + }) + } + this.imageInfo = imageInfo this.show(reference, cb) this.render() + // Auto focus and select all content of the `src.input` element. const input = this.imageSelectorContainer.querySelector('input.src') if (input) { @@ -85,6 +94,10 @@ class ImageSelector extends BaseFloat { } searchPhotos = (keyword) => { + if (!this.unsplash) { + return + } + this.loading = true this.photoList = [] this.unsplash.search.photos(keyword, 1, 40) @@ -253,11 +266,15 @@ class ImageSelector extends BaseFloat { }, { label: 'Embed link', value: 'link' - }, { - label: 'Unsplash', - value: 'unsplash' }] + if (this.unsplash) { + tabs.push({ + label: 'Unsplash', + value: 'unsplash' + }) + } + const children = tabs.map(tab => { const itemSelector = this.tab === tab.value ? 'li.active' : 'li' return h(itemSelector, h('span', { @@ -285,7 +302,7 @@ class ImageSelector extends BaseFloat { } } }, 'Choose an Image'), - h('span.description', 'Choose image from you computer.') + h('span.description', 'Choose image from your computer.') ] } else if (tab === 'link') { const altInput = h('input.alt', { @@ -355,14 +372,14 @@ class ImageSelector extends BaseFloat { } }, 'Embed Image') const bottomDes = h('span.description', [ - h('span', 'Paste web image or local image path, '), + h('span', 'Paste web image or local image path. Use '), h('a', { on: { click: event => { this.toggleMode() } } - }, `${isFullMode ? 'simple mode' : 'full mode'}`) + }, `${isFullMode ? 'simple mode' : 'full mode'}.`) ]) bodyContent = [inputWrapper, embedButton, bottomDes] } else { diff --git a/src/muya/lib/utils/exportHtml.js b/src/muya/lib/utils/exportHtml.js index 60e38915..11c6be20 100644 --- a/src/muya/lib/utils/exportHtml.js +++ b/src/muya/lib/utils/exportHtml.js @@ -3,6 +3,7 @@ import Prism from 'prismjs' import katex from 'katex' import loadRenderer from '../renderers' import githubMarkdownCss from 'github-markdown-css/github-markdown.css' +import footnoteCss from '../assets/styles/exportStyle.css' import highlightCss from 'prismjs/themes/prism.css' import katexCss from 'katex/dist/katex.css' import footerHeaderCss from '../assets/styles/headerFooterStyle.css' @@ -106,6 +107,7 @@ class ExportHtml { this.mathRendererCalled = false let html = marked(this.markdown, { superSubScript: this.muya ? this.muya.options.superSubScript : false, + footnote: this.muya ? this.muya.options.footnote : false, highlight (code, lang) { // Language may be undefined (GH#591) if (!lang) { @@ -247,6 +249,7 @@ class ExportHtml { list-style-type: decimal; } + diff --git a/src/muya/lib/utils/exportMarkdown.js b/src/muya/lib/utils/exportMarkdown.js index ca26e1e5..ca7749cd 100644 --- a/src/muya/lib/utils/exportMarkdown.js +++ b/src/muya/lib/utils/exportMarkdown.js @@ -4,8 +4,9 @@ * Before you edit or update codes in this file, * make sure you have read this bellow: * Commonmark Spec: https://spec.commonmark.org/0.29/ - * and GitHub Flavored Markdown Spec: https://github.github.com/gfm/ - * The output markdown needs to obey the standards of the two Spec. + * GitHub Flavored Markdown Spec: https://github.github.com/gfm/ + * Pandoc Markdown: https://pandoc.org/MANUAL.html#pandocs-markdown + * The output markdown needs to obey the standards of these Spec. */ class ExportMarkdown { @@ -74,6 +75,10 @@ class ExportMarkdown { result.push(this.normalizeHTML(block, indent)) break } + case 'footnote': { + result.push(this.normalizeFootnote(block, indent)) + break + } case 'multiplemath': { result.push(this.normalizeMultipleMath(block, indent)) break @@ -387,6 +392,24 @@ class ExportMarkdown { result.push(this.translateBlocks2Markdown(children, newIndent, listIndent).substring(newIndent.length)) return result.join('') } + + normalizeFootnote (block, indent) { + const result = [] + const identifier = block.children[0].text + result.push(`${indent}[^${identifier}]:`) + const hasMultipleBlocks = block.children.length > 2 || block.children[1].type !== 'p' + if (hasMultipleBlocks) { + result.push('\n') + const newIndent = indent + ' '.repeat(4) + result.push(this.translateBlocks2Markdown(block.children.slice(1), newIndent)) + } else { + result.push(' ') + const paragraphContent = block.children[1].children[0] + result.push(this.normalizeParagraphText(paragraphContent, indent)) + } + + return result.join('') + } } export default ExportMarkdown diff --git a/src/muya/lib/utils/importMarkdown.js b/src/muya/lib/utils/importMarkdown.js index b49539af..5ccbdcad 100644 --- a/src/muya/lib/utils/importMarkdown.js +++ b/src/muya/lib/utils/importMarkdown.js @@ -77,8 +77,9 @@ const importRegister = ContentState => { nextSibling: null, children: [] } - const { trimUnnecessaryCodeBlockEmptyLines } = this.muya.options - const tokens = new Lexer({ disableInline: true }).lex(markdown) + + const { trimUnnecessaryCodeBlockEmptyLines, footnote } = this.muya.options + const tokens = new Lexer({ disableInline: true, footnote }).lex(markdown) let token let block let value @@ -320,6 +321,23 @@ const importRegister = ContentState => { parentList.shift() break } + case 'footnote_start': { + block = this.createBlock('figure', { + functionType: 'footnote' + }) + const identifierInput = this.createBlock('span', { + text: token.identifier, + functionType: 'footnoteInput' + }) + this.appendChild(block, identifierInput) + this.appendChild(parentList[0], block) + parentList.unshift(block) + break + } + case 'footnote_end': { + parentList.shift() + break + } case 'list_start': { const { ordered, listType, start } = token block = this.createBlock(ordered === true ? 'ol' : 'ul') @@ -555,7 +573,6 @@ const importRegister = ContentState => { results.add(attrs.src) } else { const rawSrc = label + backlash.second - console.log(render.labels) if (render.labels.has((rawSrc).toLowerCase())) { const { href } = render.labels.get(rawSrc.toLowerCase()) const { src } = getImageInfo(href) diff --git a/src/muya/lib/utils/index.js b/src/muya/lib/utils/index.js index c4cbb103..786a38fe 100644 --- a/src/muya/lib/utils/index.js +++ b/src/muya/lib/utils/index.js @@ -387,3 +387,15 @@ export const verticalPositionInRect = (event, rect) => { const { top, height } = rect return (clientY - top) > (height / 2) ? 'down' : 'up' } + +export const collectFootnotes = (blocks) => { + const map = new Map() + for (const block of blocks) { + if (block.type === 'figure' && block.functionType === 'footnote') { + const identifier = block.children[0].text + map.set(identifier, block) + } + } + + return map +} diff --git a/src/muya/themes/default.css b/src/muya/themes/default.css index 18945cdd..60a25652 100644 --- a/src/muya/themes/default.css +++ b/src/muya/themes/default.css @@ -640,7 +640,6 @@ kbd { border-left-color: transparent; border-right-color: transparent; } - } /* end not print */ @media print { diff --git a/src/renderer/assets/styles/index.css b/src/renderer/assets/styles/index.css index 4b439020..05a86d5e 100644 --- a/src/renderer/assets/styles/index.css +++ b/src/renderer/assets/styles/index.css @@ -32,6 +32,7 @@ --iconColor: #6B737B; --codeBgColor: #d8d8d869; --codeBlockBgColor: rgba(0, 0, 0, 0.03); + --footnoteBgColor: rgba(0, 0, 0, .03); --inputBgColor: rgba(0, 0, 0, .06); --focusColor: var(--themeColor); diff --git a/src/renderer/assets/themes/dark.theme.css b/src/renderer/assets/themes/dark.theme.css index 18b0df6b..9ee44f2d 100644 --- a/src/renderer/assets/themes/dark.theme.css +++ b/src/renderer/assets/themes/dark.theme.css @@ -26,6 +26,7 @@ --iconColor: rgba(255, 255, 255, .56); --codeBgColor: #424344; --codeBlockBgColor: #424344; + --footnoteBgColor: rgba(66, 67, 68, .3); --inputBgColor: #2f3336; --focusColor: var(--themeColor); diff --git a/src/renderer/assets/themes/graphite.theme.css b/src/renderer/assets/themes/graphite.theme.css index 2d5f00bd..7044f4a8 100644 --- a/src/renderer/assets/themes/graphite.theme.css +++ b/src/renderer/assets/themes/graphite.theme.css @@ -25,6 +25,7 @@ --iconColor: rgba(150, 150, 150, .8); --codeBgColor: #d8d8d869; --codeBlockBgColor: rgba(104, 134, 170, .05); + --footnoteBgColor: rgba(0, 0, 0, .03); --inputBgColor: rgba(0, 0, 0, .06); --focusColor: var(--themeColor); diff --git a/src/renderer/assets/themes/material-dark.theme.css b/src/renderer/assets/themes/material-dark.theme.css index 338d8539..d1854dc9 100644 --- a/src/renderer/assets/themes/material-dark.theme.css +++ b/src/renderer/assets/themes/material-dark.theme.css @@ -26,6 +26,7 @@ --iconColor: rgba(255, 255, 255, .56); --codeBgColor: #d8d8d869; --codeBlockBgColor: #3f454c; + --footnoteBgColor: rgba(66, 67, 68, .5); --inputBgColor: rgba(0, 0, 0, .1); --focusColor: var(--themeColor); diff --git a/src/renderer/assets/themes/one-dark.theme.css b/src/renderer/assets/themes/one-dark.theme.css index 42b1c162..624fa469 100644 --- a/src/renderer/assets/themes/one-dark.theme.css +++ b/src/renderer/assets/themes/one-dark.theme.css @@ -27,6 +27,7 @@ --iconColor: rgba(255, 255, 255, .56); --codeBgColor: #3a3f4b; --codeBlockBgColor: #3a3f4b; + --footnoteBgColor: rgba(66, 67, 68, .5); --inputBgColor: rgba(0, 0, 0, .1); --focusColor: #568af2; diff --git a/src/renderer/assets/themes/ulysses.theme.css b/src/renderer/assets/themes/ulysses.theme.css index a1b38ce7..95b6b7cf 100644 --- a/src/renderer/assets/themes/ulysses.theme.css +++ b/src/renderer/assets/themes/ulysses.theme.css @@ -25,6 +25,7 @@ --iconColor: rgba(101, 101, 101, .8); --codeBgColor: #d8d8d869; --codeBlockBgColor: rgba(12, 139, 186, .05); + --footnoteBgColor: rgba(0, 0, 0, .03); --inputBgColor: rgba(0, 0, 0, .06); --focusColor: var(--themeColor); diff --git a/src/renderer/components/editorWithTabs/editor.vue b/src/renderer/components/editorWithTabs/editor.vue index 091cffeb..5060207e 100644 --- a/src/renderer/components/editorWithTabs/editor.vue +++ b/src/renderer/components/editorWithTabs/editor.vue @@ -88,6 +88,7 @@ import ImageToolbar from 'muya/lib/ui/imageToolbar' import Transformer from 'muya/lib/ui/transformer' import FormatPicker from 'muya/lib/ui/formatPicker' import LinkTools from 'muya/lib/ui/linkTools' +import FootnoteTool from 'muya/lib/ui/footnoteTool' import TableBarTools from 'muya/lib/ui/tableTools' import FrontMenu from 'muya/lib/ui/frontMenu' import Search from '../search' @@ -136,6 +137,7 @@ export default { listIndentation: state => state.preferences.listIndentation, frontmatterType: state => state.preferences.frontmatterType, superSubScript: state => state.preferences.superSubScript, + footnote: state => state.preferences.footnote, lineHeight: state => state.preferences.lineHeight, fontSize: state => state.preferences.fontSize, codeFontSize: state => state.preferences.codeFontSize, @@ -251,6 +253,12 @@ export default { editor.setOptions({ superSubScript: value }, true) } }, + footnote: function (value, oldValue) { + const { editor } = this + if (value !== oldValue && editor) { + editor.setOptions({ footnote: value }, true) + } + }, hideQuickInsertHint: function (value, oldValue) { const { editor } = this if (value !== oldValue && editor) { @@ -454,6 +462,7 @@ export default { listIndentation, frontmatterType, superSubScript, + footnote, hideQuickInsertHint, editorLineWidth, theme, @@ -468,7 +477,7 @@ export default { Muya.use(EmojiPicker) Muya.use(ImagePathPicker) Muya.use(ImageSelector, { - accessKey: process.env.UNSPLASH_ACCESS_KEY, + unsplashAccessKey: process.env.UNSPLASH_ACCESS_KEY, photoCreatorClick: this.photoCreatorClick }) Muya.use(Transformer) @@ -478,6 +487,7 @@ export default { Muya.use(LinkTools, { jumpClick: this.jumpClick }) + Muya.use(FootnoteTool) Muya.use(TableBarTools) const options = { @@ -497,6 +507,7 @@ export default { listIndentation, frontmatterType, superSubScript, + footnote, hideQuickInsertHint, hideLinkPopup, spellcheckEnabled: spellcheckerEnabled, diff --git a/src/renderer/contextMenu/editor/spellcheck.js b/src/renderer/contextMenu/editor/spellcheck.js index d49becad..7be59cd0 100644 --- a/src/renderer/contextMenu/editor/spellcheck.js +++ b/src/renderer/contextMenu/editor/spellcheck.js @@ -1,6 +1,7 @@ import { remote } from 'electron' import log from 'electron-log' import bus from '@/bus' +import { getLanguageName } from '@/spellchecker/languageMap' import { SEPARATOR } from './menuItems' const { MenuItem } = remote @@ -24,7 +25,7 @@ export default (spellchecker, selectedWord, wordSuggestions, replaceCallback) => const availableDictionariesSubmenu = [] for (const dict of availableDictionaries) { availableDictionariesSubmenu.push(new MenuItem({ - label: dict, + label: getLanguageName(dict), enabled: dict !== currentLanguage, click () { bus.$emit('switch-spellchecker-language', dict) diff --git a/src/renderer/pages/app.vue b/src/renderer/pages/app.vue index 50dad342..2b42f9ed 100644 --- a/src/renderer/pages/app.vue +++ b/src/renderer/pages/app.vue @@ -146,6 +146,7 @@ export default { dispatch('LINTEN_FOR_PRINT_SERVICE_CLEARUP') dispatch('LINTEN_FOR_EXPORT_SUCCESS') dispatch('LISTEN_FOR_FILE_CHANGE') + dispatch('LISTEN_WINDOW_ZOOM') // module: notification dispatch('LISTEN_FOR_NOTIFICATION') diff --git a/src/renderer/prefComponents/markdown/index.vue b/src/renderer/prefComponents/markdown/index.vue index a6e6d8ed..8a939cce 100644 --- a/src/renderer/prefComponents/markdown/index.vue +++ b/src/renderer/prefComponents/markdown/index.vue @@ -54,6 +54,12 @@ :onChange="value => onSelectChange('superSubScript', value)" more="https://pandoc.org/MANUAL.html#superscripts-and-subscripts" > + @@ -95,7 +101,8 @@ export default { tabSize: state => state.preferences.tabSize, listIndentation: state => state.preferences.listIndentation, frontmatterType: state => state.preferences.frontmatterType, - superSubScript: state => state.preferences.superSubScript + superSubScript: state => state.preferences.superSubScript, + footnote: state => state.preferences.footnote }) }, methods: { diff --git a/src/renderer/prefComponents/spellchecker/index.vue b/src/renderer/prefComponents/spellchecker/index.vue index 5e7e67b5..5de8b4c3 100644 --- a/src/renderer/prefComponents/spellchecker/index.vue +++ b/src/renderer/prefComponents/spellchecker/index.vue @@ -8,7 +8,7 @@ >
    -
    Available Hunspell dictionaries. Please add additional language dictionaries via button below.
    +
    List of available Hunspell dictionaries. Please add additional language dictionaries via drop-down menu below.
    @@ -65,7 +65,7 @@ -
    Add new dictionaries to Hunspell.
    +
    Download new dictionaries for Hunspell.
    + +
    Open the themes folder @@ -27,21 +34,25 @@