Add support for relative image directory (#2439)

This commit is contained in:
Felix Häusler 2020-12-23 23:24:27 +01:00 committed by GitHub
parent 38935cc40c
commit 2b53a414f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 149 additions and 27 deletions

28
docs/IMAGES.md Normal file
View File

@ -0,0 +1,28 @@
# Image support
Mark Text can automatically copy your images into a specified directory or handle images from clipboard.
### Upload to cloud using selected uploader
Please see [here](IMAGE_UPLOADER_CONFIGRATION.md) for more information.
### Move to designated local folder
All images are automatically copied into the specified local directory that may be relative.
**Prefer relative assets folder:**
When this option is enabled, all images are copied relative to the opened file or the root directory when a project is opened. You can specify the path via the *relative image folder name* text box. The local resource directory is used if the file is not saved.
NB: The assets directory name must be a valid path name and Mark Text need write access to the directory.
Examples for relative paths:
- `assets`
- `../assets`
- `.`: current file directory
- `assets/123`
### Keep original location
Mark Text only saves images from clipboard into the specified local directory.

View File

@ -355,6 +355,16 @@
], ],
"default": "path" "default": "path"
}, },
"imagePreferRelativeDirectory": {
"description": "Image--Whether to prefer the relative image directory.",
"type": "boolean",
"default": false
},
"imageRelativeDirectoryName": {
"description": "Image--The relative image folder name.",
"type": "string",
"default": "assets"
},
"sideBarVisibility": { "sideBarVisibility": {
"description": "View--Whether the side bar is visible.", "description": "View--Whether the side bar is visible.",
"type": "boolean", "type": "boolean",

View File

@ -74,9 +74,11 @@
<script> <script>
import { shell } from 'electron' import { shell } from 'electron'
import path from 'path'
import log from 'electron-log' import log from 'electron-log'
import { mapState } from 'vuex' import { mapState } from 'vuex'
// import ViewImage from 'view-image' // import ViewImage from 'view-image'
import { isChildOfDirectory } from 'common/filesystem/paths'
import Muya from 'muya/lib' import Muya from 'muya/lib'
import TablePicker from 'muya/lib/ui/tablePicker' import TablePicker from 'muya/lib/ui/tablePicker'
import QuickInsert from 'muya/lib/ui/quickInsert' import QuickInsert from 'muya/lib/ui/quickInsert'
@ -99,7 +101,7 @@ import notice from '@/services/notification'
import Printer from '@/services/printService' import Printer from '@/services/printService'
import { isOsSpellcheckerSupported, offsetToWordCursor, validateLineCursor, SpellChecker } from '@/spellchecker' import { isOsSpellcheckerSupported, offsetToWordCursor, validateLineCursor, SpellChecker } from '@/spellchecker'
import { delay, isOsx, animatedScrollTo } from '@/util' import { delay, isOsx, animatedScrollTo } from '@/util'
import { moveImageToFolder, uploadImage } from '@/util/fileSystem' import { moveImageToFolder, moveToRelativeFolder, uploadImage } from '@/util/fileSystem'
import { guessClipboardFilePath } from '@/util/clipboard' import { guessClipboardFilePath } from '@/util/clipboard'
import { getCssForOptions, getHtmlToc } from '@/util/pdf' import { getCssForOptions, getHtmlToc } from '@/util/pdf'
import { addCommonStyle, setEditorWidth } from '@/util/theme' import { addCommonStyle, setEditorWidth } from '@/util/theme'
@ -154,6 +156,8 @@ export default {
autoCheck: state => state.preferences.autoCheck, autoCheck: state => state.preferences.autoCheck,
editorLineWidth: state => state.preferences.editorLineWidth, editorLineWidth: state => state.preferences.editorLineWidth,
imageInsertAction: state => state.preferences.imageInsertAction, imageInsertAction: state => state.preferences.imageInsertAction,
imagePreferRelativeDirectory: state => state.preferences.imagePreferRelativeDirectory,
imageRelativeDirectoryName: state => state.preferences.imageRelativeDirectoryName,
imageFolderPath: state => state.preferences.imageFolderPath, imageFolderPath: state => state.preferences.imageFolderPath,
theme: state => state.preferences.theme, theme: state => state.preferences.theme,
sequenceTheme: state => state.preferences.sequenceTheme, sequenceTheme: state => state.preferences.sequenceTheme,
@ -165,6 +169,7 @@ export default {
spellcheckerLanguage: state => state.preferences.spellcheckerLanguage, spellcheckerLanguage: state => state.preferences.spellcheckerLanguage,
currentFile: state => state.editor.currentFile, currentFile: state => state.editor.currentFile,
projectTree: state => state.project.projectTree,
// edit modes // edit modes
typewriter: state => state.preferences.typewriter, typewriter: state => state.preferences.typewriter,
@ -768,9 +773,26 @@ export default {
}, },
async imageAction (image, id, alt = '') { async imageAction (image, id, alt = '') {
const { imageInsertAction, imageFolderPath, preferences } = this const {
imageInsertAction,
imageFolderPath,
imagePreferRelativeDirectory,
imageRelativeDirectoryName,
preferences
} = this
const { pathname } = this.currentFile const { pathname } = this.currentFile
let result
// Figure out the current working directory.
let cwd = pathname ? path.dirname(pathname) : null
if (pathname && this.projectTree) {
const { pathname: rootPath } = this.projectTree
if (rootPath && isChildOfDirectory(rootPath, pathname)) {
// Save assets relative to root directory.
cwd = rootPath
}
}
let result = ''
switch (imageInsertAction) { switch (imageInsertAction) {
case 'upload': { case 'upload': {
try { try {
@ -787,6 +809,9 @@ export default {
} }
case 'folder': { case 'folder': {
result = await moveImageToFolder(pathname, image, imageFolderPath) result = await moveImageToFolder(pathname, image, imageFolderPath)
if (cwd && imagePreferRelativeDirectory) {
result = moveToRelativeFolder(cwd, result, imageRelativeDirectoryName)
}
break break
} }
case 'path': { case 'path': {
@ -795,6 +820,11 @@ export default {
} else { } else {
// Move image to image folder if it's Blob object. // Move image to image folder if it's Blob object.
result = await moveImageToFolder(pathname, image, imageFolderPath) result = await moveImageToFolder(pathname, image, imageFolderPath)
// Respect user preferences if file exist on disk.
if (cwd && imagePreferRelativeDirectory) {
result = moveToRelativeFolder(cwd, result, imageRelativeDirectoryName)
}
} }
break break
} }

View File

@ -2,8 +2,8 @@
<div class="pref-image"> <div class="pref-image">
<h4>Image</h4> <h4>Image</h4>
<section class="image-ctrl"> <section class="image-ctrl">
<div>Default action after image is inserted from local folder <div>Default action after image is inserted from local folder or clipboard
<el-tooltip class='item' effect='dark' content='Mark Text can not get image path from paste event on Linux.' placement='top-start'> <el-tooltip class='item' effect='dark' content='Clipboard handling is only fully supported on macOS and Windows.' placement='top-start'>
<i class="el-icon-info"></i> <i class="el-icon-info"></i>
</el-tooltip> </el-tooltip>
</div> </div>
@ -21,23 +21,45 @@
<el-button size="mini" @click="modifyImageFolderPath">Modify</el-button> <el-button size="mini" @click="modifyImageFolderPath">Modify</el-button>
<el-button size="mini" @click="openImageFolder">Open Folder</el-button> <el-button size="mini" @click="openImageFolder">Open Folder</el-button>
</div> </div>
<bool
description="Prefer relative assets folder"
more="https://github.com/marktext/marktext/blob/develop/docs/IMAGES.md"
:bool="imagePreferRelativeDirectory"
:onChange="value => onSelectChange('imagePreferRelativeDirectory', value)"
></bool>
<text-box
description="Relative image folder name"
:input="imageRelativeDirectoryName"
:regexValidator="/^(?:$|(?![a-zA-Z]:)[^\/\\].*$)/"
:defaultValue="relativeDirectoryNamePlaceholder"
:onChange="value => onSelectChange('imageRelativeDirectoryName', value)"
></text-box>
</section> </section>
</div> </div>
</template> </template>
<script> <script>
import Separator from '../common/separator' import { mapState } from 'vuex'
import { shell } from 'electron' import { shell } from 'electron'
import Bool from '../common/bool'
import Separator from '../common/separator'
import TextBox from '../common/textBox'
export default { export default {
components: { components: {
Separator Bool,
Separator,
TextBox
}, },
data () { data () {
return { return {
} }
}, },
computed: { computed: {
...mapState({
imagePreferRelativeDirectory: state => state.preferences.imagePreferRelativeDirectory,
imageRelativeDirectoryName: state => state.preferences.imageRelativeDirectoryName
}),
imageInsertAction: { imageInsertAction: {
get: function () { get: function () {
return this.$store.state.preferences.imageInsertAction return this.$store.state.preferences.imageInsertAction
@ -51,6 +73,11 @@ export default {
get: function () { get: function () {
return this.$store.state.preferences.imageFolderPath return this.$store.state.preferences.imageFolderPath
} }
},
relativeDirectoryNamePlaceholder: {
get: function () {
return this.$store.state.preferences.imageRelativeDirectoryName || 'assets'
}
} }
}, },
methods: { methods: {
@ -59,6 +86,9 @@ export default {
}, },
modifyImageFolderPath () { modifyImageFolderPath () {
return this.$store.dispatch('SET_IMAGE_FOLDER_PATH') return this.$store.dispatch('SET_IMAGE_FOLDER_PATH')
},
onSelectChange (type, value) {
this.$store.dispatch('SET_SINGLE_PREFERENCE', { type, value })
} }
} }
} }

View File

@ -35,6 +35,8 @@ const state = {
textDirection: 'ltr', textDirection: 'ltr',
hideQuickInsertHint: false, hideQuickInsertHint: false,
imageInsertAction: 'folder', imageInsertAction: 'folder',
imagePreferRelativeDirectory: false,
imageRelativeDirectoryName: 'assets',
hideLinkPopup: false, hideLinkPopup: false,
autoCheck: false, autoCheck: false,

View File

@ -1,29 +1,59 @@
import path from 'path' import path from 'path'
import crypto from 'crypto'
import { clipboard } from 'electron' import { clipboard } from 'electron'
import fse from 'fs-extra' import fs from 'fs-extra'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import Octokit from '@octokit/rest' import Octokit from '@octokit/rest'
import { ensureDirSync } from 'common/filesystem'
import { isImageFile } from 'common/filesystem/paths' import { isImageFile } from 'common/filesystem/paths'
import { dataURItoBlob, getContentHash } from './index' import { dataURItoBlob } from './index'
import axios from '../axios' import axios from '../axios'
export const create = (pathname, type) => { export const create = (pathname, type) => {
if (type === 'directory') { if (type === 'directory') {
return fse.ensureDir(pathname) return fs.ensureDir(pathname)
} else { } else {
return fse.outputFile(pathname, '') return fs.outputFile(pathname, '')
} }
} }
export const paste = ({ src, dest, type }) => { export const paste = ({ src, dest, type }) => {
return type === 'cut' ? fse.move(src, dest) : fse.copy(src, dest) return type === 'cut' ? fs.move(src, dest) : fs.copy(src, dest)
} }
export const rename = (src, dest) => { export const rename = (src, dest) => {
return fse.move(src, dest) return fs.move(src, dest)
}
export const getHash = (content, encoding, type) => {
return crypto.createHash(type).update(content, encoding).digest('hex')
}
export const getContentHash = content => {
return getHash(content, 'utf8', 'sha1')
}
export const moveToRelativeFolder = async (cwd, imagePath, relativeName) => {
if (!relativeName) {
// Use fallback name according settings description
relativeName = 'assets'
} else if (path.isAbsolute(relativeName)) {
throw new Error('Invalid relative directory name')
}
// Path combination:
// - markdown file directory + relative directory name or
// - root directory + relative directory name
const absPath = path.resolve(cwd, relativeName)
ensureDirSync(absPath)
const dstPath = path.resolve(absPath, path.basename(imagePath))
await fs.move(imagePath, dstPath, { overwrite: true })
return dstPath
} }
export const moveImageToFolder = async (pathname, image, dir) => { export const moveImageToFolder = async (pathname, image, dir) => {
ensureDirSync(dir)
const isPath = typeof image === 'string' const isPath = typeof image === 'string'
if (isPath) { if (isPath) {
const dirname = path.dirname(pathname) const dirname = path.dirname(pathname)
@ -39,7 +69,7 @@ export const moveImageToFolder = async (pathname, image, dir) => {
const hash = getContentHash(imagePath) const hash = getContentHash(imagePath)
// To avoid name conflict. // To avoid name conflict.
const hashFilePath = path.join(dir, `${hash}${extname}`) const hashFilePath = path.join(dir, `${hash}${extname}`)
await fse.copy(imagePath, hashFilePath) await fs.copy(imagePath, hashFilePath)
return hashFilePath return hashFilePath
} else { } else {
return Promise.resolve(image) return Promise.resolve(image)
@ -55,7 +85,7 @@ export const moveImageToFolder = async (pathname, image, dir) => {
fileReader.readAsBinaryString(image) fileReader.readAsBinaryString(image)
}) })
await fse.writeFile(imagePath, binaryString, 'binary') await fs.writeFile(imagePath, binaryString, 'binary')
return imagePath return imagePath
} }
} }
@ -137,11 +167,11 @@ export const uploadImage = async (pathname, image, preferences) => {
const imagePath = path.resolve(dirname, image) const imagePath = path.resolve(dirname, image)
const isImage = isImageFile(imagePath) const isImage = isImageFile(imagePath)
if (isImage) { if (isImage) {
const { size } = await fse.stat(imagePath) const { size } = await fs.stat(imagePath)
if (size > MAX_SIZE) { if (size > MAX_SIZE) {
notification() notification()
} else { } else {
const imageFile = await fse.readFile(imagePath) const imageFile = await fs.readFile(imagePath)
const blobFile = new Blob([imageFile]) const blobFile = new Blob([imageFile])
if (currentUploader === 'smms') { if (currentUploader === 'smms') {
uploadToSMMS(blobFile) uploadToSMMS(blobFile)

View File

@ -1,5 +1,3 @@
import crypto from 'crypto'
export const delay = time => { export const delay = time => {
let timerId let timerId
let rejectFn let rejectFn
@ -210,14 +208,6 @@ export const cloneObj = (obj, deepCopy = true) => {
return deepCopy ? JSON.parse(JSON.stringify(obj)) : Object.assign({}, obj) return deepCopy ? JSON.parse(JSON.stringify(obj)) : Object.assign({}, obj)
} }
export const getHash = (content, encoding, type) => {
return crypto.createHash(type).update(content, encoding).digest('hex')
}
export const getContentHash = content => {
return getHash(content, 'utf8', 'sha1')
}
export const isOsx = process.platform === 'darwin' export const isOsx = process.platform === 'darwin'
export const isWindows = process.platform === 'win32' export const isWindows = process.platform === 'win32'
export const isLinux = process.platform === 'linux' export const isLinux = process.platform === 'linux'

View File

@ -31,6 +31,8 @@
"textDirection": "ltr", "textDirection": "ltr",
"hideQuickInsertHint": false, "hideQuickInsertHint": false,
"imageInsertAction": "path", "imageInsertAction": "path",
"imagePreferRelativeDirectory": false,
"imageRelativeDirectoryName": "assets",
"hideLinkPopup": false, "hideLinkPopup": false,
"autoCheck": false, "autoCheck": false,