@@ -36,24 +36,27 @@
type = "button"
class = "action-btn"
: class = "{ 'force-disabled': isDocUploadDisabled }"
:aria-label = "t('uploadFile ')"
:title = "docU ploadButtonTitle"
@click ="triggerFile Upload"
:aria-label = "t('upload')"
:title = "u ploadButtonTitle"
@click ="triggerUpload"
>
< svg width = "20" height = "20" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " >
< path d = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / >
< polyline points = "14 2 14 8 20 8" / >
< rect x = "7" y = "11" width = "8" height = "6" rx = "1" stroke -width = " 1.5 " / >
< circle cx = "9" cy = "13" r = "0.8" fill = "currentColor" / >
< path d = "M7 16l2-2 2 2" stroke -width = " 1.5 " / >
< / svg >
< span class = "btn-tooltip" > { { t ( 'uploadFile ' ) } } < / span >
< span class = "btn-tooltip" > { { t ( 'upload' ) } } < / span >
< / button >
< input type = "file" ref = "uploadFile InputRef" @change ="handleUploadFile " accept= ".txt,.json,.toml,.yaml,.yml,.docx,.pptx,.pdf,text/plain,application/json,text/yaml,text/x-yaml,application/x-yaml,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation " multiple style = "display:none" >
< input type = "file" ref = "uploadInputRef" @change ="handleUpload" : accept= "acceptAll " multiple style = "display:none" >
< button
type = "button"
class = "action-btn"
:aria-label = "t('importMd')"
:title = "t('importMd')"
@click ="triggerUploa d"
@click ="triggerImportM d"
>
< svg width = "20" height = "20" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " >
< path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" / >
@@ -62,7 +65,7 @@
< / svg >
< span class = "btn-tooltip" > { { t ( 'importMd' ) } } < / span >
< / button >
< input type = "file" ref = "file InputRef" @change ="handleFileUploa d" accept = ".md,text/markdown,text/x-markdown" style = "display:none" >
< input type = "file" ref = "md InputRef" @change ="handleImportM d" accept = ".md,text/markdown,text/x-markdown" style = "display:none" >
< div class = "export-btn-wrapper" >
< button
@@ -87,29 +90,6 @@
< button type = "button" @click ="() => { exportPdf(); showExportDropdown = false; }" > {{ t ( ' exportPdf ' ) }} < / button >
< / div >
< / div >
< div class = "image-btn-wrapper" >
< button
type = "button"
class = "action-btn"
:aria-label = "t('uploadImg')"
:title = "t('uploadImg')"
@click ="toggleImageDropdown"
>
< svg width = "20" height = "20" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " >
< rect x = "3" y = "3" width = "18" height = "18" rx = "2" ry = "2" / >
< circle cx = "8.5" cy = "8.5" r = "1.5" / >
< polyline points = "21 15 16 10 5 21" / >
< / svg >
< span class = "btn-tooltip" > { { t ( 'uploadImg' ) } } < / span >
< / button >
< div v-if = "showImageDropdown" class="image-dropdown" >
< button v-if = "supportsCameraCapture" type="button" @click="triggerCameraCapture" > {{ cameraUploadLabel }} < / button >
< button type = "button" @click ="triggerImageUpload" > {{ t ( ' uploadImg ' ) }} < / button >
< button type = "button" @click ="showUrlDialog = true; showImageDropdown = false" > {{ t ( ' insertUrl ' ) }} < / button >
< / div >
< / div >
< input type = "file" ref = "imageInputRef" @change ="handleImageUpload" accept = "image/*" style = "display:none" >
< input type = "file" ref = "cameraInputRef" @change ="handleImageUpload" accept = "image/*" capture = "environment" style = "display:none" >
< button
type = "button"
@@ -161,22 +141,6 @@
< / Transition >
< / div >
< / div >
< div v-if = "showUrlDialog" class="url-dialog-overlay" @click.self="showUrlDialog = false" >
< div class = "url-dialog" >
< h3 > { { t ( 'insertUrl' ) } } < / h3 >
< input
v-model = "imageUrl"
type = "url"
placeholder = "https://..."
@keyup.enter ="insertImageFromUrl"
/ >
< div class = "url-dialog-buttons" >
< button type = "button" class = "dialog-btn primary" @click ="insertImageFromUrl" > {{ t ( ' insert ' ) }} < / button >
< button type = "button" class = "dialog-btn" @click ="showUrlDialog = false; imageUrl = ''" > {{ t ( ' cancel ' ) }} < / button >
< / div >
< / div >
< / div >
< / div >
< div v-if = "uploadProgress" class="upload-progress-overlay" >
@@ -214,17 +178,12 @@ const t = (key) => settings.t[key]
const initialMarkdown = computed ( ( ) => settings . initialMarkdown )
const root = ref ( null )
const file InputRef = ref ( null )
const uploadFile InputRef = ref ( null )
const imageInputRef = ref ( null )
const cameraInputRef = ref ( null )
const upload InputRef = ref ( null )
const md InputRef = ref ( null )
const aiEnabled = ref ( true )
const contentSize = ref ( 0 )
const showImageDropdown = ref ( false )
const showExportDropdown = ref ( false )
const showUrlDialog = ref ( false )
const showSizeTooltip = ref ( false )
const imageUrl = ref ( '' )
const canUndo = ref ( false )
const canRedo = ref ( false )
const isDocUploadDisabled = ref ( false )
@@ -233,20 +192,28 @@ const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed ( ( ) => Math . floor ( contentSize . value / 1024 ) )
const undoLabel = computed ( ( ) => t ( 'undo' ) || 'Undo' )
const redoLabel = computed ( ( ) => t ( 'redo' ) || 'Redo' )
const cameraUploadLabel = computed ( ( ) => t ( 'cameraUpload' ) || 'Use Camera' )
const API _KEY = 'your-secret-key-here'
const supportsCameraCapture = computed ( ( ) => {
if ( typeof navigator === 'undefined' ) return false
const ua = navigator . userAgent || ''
return /Android|iPhone|iPad|iPod|Mobile/i . test ( ua )
} )
const aiButtonLabel = computed ( ( ) => {
if ( isOverLimit . value ) return t ( 'docTooLarge' )
return aiEnabled . value ? t ( 'disableAI' ) : t ( 'enableAI' )
} )
const docU ploadButtonTitle = computed ( ( ) => {
const u ploadButtonTitle = computed ( ( ) => {
if ( isDocUploadDisabled . value ) return t ( 'uploadDocInBlockWarning' ) || '当前光标位置不能插入文件'
return t ( 'uploadFile ' )
return t ( 'upload' )
} )
const acceptAll = computed ( ( ) => {
const types = [
'.txt' , '.json' , '.toml' , '.yaml' , '.yml' ,
'.docx' , '.pptx' , '.pdf' ,
'.png' , '.jpg' , '.jpeg' , '.gif' , '.webp' , '.bmp' , '.svg' , '.heic' , '.heif' , '.avif' ,
'text/plain' , 'application/json' ,
'text/yaml' , 'text/x-yaml' , 'application/x-yaml' ,
'application/pdf' ,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ,
'application/vnd.openxmlformats-officedocument.presentationml.presentation' ,
'image/*'
]
return types . join ( ',' )
} )
let crepe = null
@@ -644,10 +611,8 @@ onMounted(async () => {
if ( ! e . target . closest ( '.export-btn-wrapper' ) ) {
showExportDropdown . value = false
}
if ( ! e . target . closest ( '.image-btn-wrapper' ) ) {
showImageDropdown . value = false
}
} )
} )
if ( ! root . value ) throw new Error ( 'root.value is null' )
@@ -817,39 +782,145 @@ const exportPdf = async () => {
}
const triggerUpload = ( ) => {
fileInputRef . value ? . click ( )
if ( isDocUploadDisabled . value ) return
uploadInputRef . value ? . click ( )
}
const handleFile Upload = async ( event ) => {
const handleUpload = async ( event ) => {
const input = event . target
const files = Array . from ( input . files || [ ] )
if ( files . length === 0 ) return
const BATCH _LIMIT = 10
const MAX _FILE _SIZE = 50 * 1024 * 1024
if ( files . length > BATCH _LIMIT ) {
alert ( t ( 'uploadBatchLimit' ) || ` 一次最多上传 ${ BATCH _LIMIT } 个文件 ` )
input . value = ''
return
}
for ( const file of files ) {
if ( file . size > MAX _FILE _SIZE ) {
alert ( t ( 'uploadSizeLimit' ) || ` ${ file . name } 超过 ${ MAX _FILE _SIZE / 1024 / 1024 } MB限制 ` )
input . value = ''
return
}
}
const imageFiles = files . filter ( isImageFile )
const docFiles = files . filter ( f => ! isImageFile ( f ) )
if ( docFiles . length > 0 && isDocUploadDisabled . value ) {
alert ( t ( 'uploadDocInBlockWarning' ) || '当前光标位置不能插入文件' )
input . value = ''
return
}
for ( const file of files ) {
const isSupported = isImageFile ( file ) || isSupportedDocFile ( file )
if ( ! isSupported ) {
alert ( t ( 'uploadFileTypeWarning' ) || '不支持的文件类型' )
input . value = ''
return
}
}
clearCurrentGhost ( )
for ( const file of imageFiles ) {
const objectUrl = await prepareImageFile ( file )
if ( objectUrl ) {
insertImageAtCursor ( objectUrl )
}
}
if ( docFiles . length > 0 ) {
uploadProgress . value = { current : 0 , total : docFiles . length , filename : '' }
const results = [ ]
const errors = [ ]
for ( let index = 0 ; index < docFiles . length ; index ++ ) {
const file = docFiles [ index ]
uploadProgress . value = { current : index + 1 , total : docFiles . length , filename : file . name }
try {
const docType = getDocTypeFromFilename ( file . name )
let content = ''
if ( isTextFile ( file ) ) {
content = await file . text ( )
} else if ( isConvertibleFile ( file ) ) {
content = await convertFileToMarkdown ( file )
} else {
throw new Error ( '不支持的文件类型' )
}
if ( ! content ) {
throw new Error ( '文档解析结果为空' )
}
results . push ( {
docType ,
docName : file . name || ` document. ${ docType } ` ,
content ,
index ,
} )
} catch ( e ) {
const message = e instanceof Error ? e . message : ''
errors . push ( { filename : file . name , message } )
}
}
uploadProgress . value = null
results . sort ( ( a , b ) => a . index - b . index )
const blocksToInsert = results . map ( ( { docType , docName , content } ) => ( {
docType ,
docName ,
content ,
uploadTime : new Date ( ) . toISOString ( ) ,
collapsed : false ,
} ) )
insertMultipleDocBlocks ( blocksToInsert )
if ( errors . length > 0 ) {
const failCount = errors . length
const errorMsgs = errors . map ( e => ` ${ e . filename } : ${ e . message } ` ) . join ( '\n' )
alert ( ` 上传失败 ${ failCount } 个文件: \ n \ n ${ errorMsgs } ` )
}
}
input . value = ''
}
const triggerImportMd = ( ) => {
mdInputRef . value ? . click ( )
}
const handleImportMd = async ( event ) => {
const input = event . target
const file = input . files ? . [ 0 ]
if ( ! file ) return
if ( isImage File( file ) ) {
const objectUrl = await prepareImageFile ( file )
if ( objectUrl ) {
clearCurrentGhost ( )
insertImageAtCursor ( objectUrl )
}
if ( ! isMarkdown File( file ) ) {
alert ( t ( 'uploadMdTypeWarning' ) || '仅支持 Markdown( .md) 文件' )
input . value = ''
return
}
if ( ! isMarkdownFile ( file ) ) {
warnUnsupportedUploadType ( )
input . value = ''
return
}
try {
const text = await file . text ( )
if ( crepe && crepe . editor ) {
crepe . editor . action ( replaceAll ( transformSpecialDocBlocksToLegacy ( text ) ) )
}
} catch {
// File upload error, ignore
// ignore
}
input . value = ''
}
@@ -867,24 +938,8 @@ const toggleAI = async () => {
} )
}
const toggleImageDropdown = ( ) => {
showImageDropdown . value = ! showImageDropdown . value
showExportDropdown . value = false
}
const toggleExportDropdown = ( ) => {
showExportDropdown . value = ! showExportDropdown . value
showImageDropdown . value = false
}
const triggerImageUpload = ( ) => {
showImageDropdown . value = false
imageInputRef . value ? . click ( )
}
const triggerCameraCapture = ( ) => {
showImageDropdown . value = false
cameraInputRef . value ? . click ( )
}
const insertImageAtCursor = ( src ) => {
@@ -990,135 +1045,6 @@ const insertMultipleDocBlocks = (blocks) => {
} )
}
const triggerFileUpload = ( ) => {
if ( isDocUploadDisabled . value ) return
uploadFileInputRef . value ? . click ( )
}
const handleUploadFile = async ( event ) => {
const input = event . target
const files = Array . from ( input . files || [ ] )
if ( files . length === 0 ) return
const BATCH _LIMIT = 10
const MAX _FILE _SIZE = 50 * 1024 * 1024
if ( files . length > BATCH _LIMIT ) {
alert ( t ( 'uploadBatchLimit' ) || ` 一次最多上传 ${ BATCH _LIMIT } 个文件 ` )
input . value = ''
return
}
for ( const file of files ) {
if ( file . size > MAX _FILE _SIZE ) {
alert ( t ( 'uploadSizeLimit' ) || ` ${ file . name } 超过 ${ MAX _FILE _SIZE / 1024 / 1024 } MB限制 ` )
input . value = ''
return
}
}
for ( const file of files ) {
if ( ! isSupportedDocFile ( file ) ) {
alert ( t ( 'uploadDocTypeWarning' ) || '仅支持 txt、docx、pptx、pdf 格式的文档' )
input . value = ''
return
}
}
if ( isDocUploadDisabled . value || ! crepe ) {
alert ( t ( 'uploadDocInBlockWarning' ) || '当前光标位置不能插入文件' )
input . value = ''
return
}
const total = files . length
uploadProgress . value = { current : 0 , total , filename : '' }
const results = [ ]
const errors = [ ]
for ( let index = 0 ; index < files . length ; index ++ ) {
const file = files [ index ]
uploadProgress . value = { current : index + 1 , total , filename : file . name }
try {
const docType = getDocTypeFromFilename ( file . name )
let content = ''
if ( isTextFile ( file ) ) {
content = await file . text ( )
} else if ( isConvertibleFile ( file ) ) {
content = await convertFileToMarkdown ( file )
} else {
throw new Error ( '不支持的文件类型' )
}
if ( ! content ) {
throw new Error ( '文档解析结果为空' )
}
results . push ( {
docType ,
docName : file . name || ` document. ${ docType } ` ,
content ,
index ,
} )
} catch ( e ) {
const message = e instanceof Error ? e . message : ''
errors . push ( { filename : file . name , message } )
}
}
uploadProgress . value = null
clearCurrentGhost ( )
results . sort ( ( a , b ) => a . index - b . index )
const blocksToInsert = results . map ( ( { docType , docName , content } ) => ( {
docType ,
docName ,
content ,
uploadTime : new Date ( ) . toISOString ( ) ,
collapsed : false ,
} ) )
insertMultipleDocBlocks ( blocksToInsert )
if ( errors . length > 0 ) {
const failCount = errors . length
const errorMsgs = errors . map ( e => ` ${ e . filename } : ${ e . message } ` ) . join ( '\n' )
alert ( ` 上传失败 ${ failCount } 个文件: \ n \ n ${ errorMsgs } ` )
}
input . value = ''
}
const handleImageUpload = async ( event ) => {
const input = event . target
const file = input . files ? . [ 0 ]
if ( ! file ) return
const objectUrl = await prepareImageFile ( file )
if ( ! objectUrl ) {
input . value = ''
return
}
clearCurrentGhost ( )
insertImageAtCursor ( objectUrl )
input . value = ''
}
const insertImageFromUrl = ( ) => {
const url = imageUrl . value . trim ( )
if ( ! url ) return
insertImageAtCursor ( url )
imageUrl . value = ''
showUrlDialog . value = false
}
onUnmounted ( ( ) => {
if ( markdownSyncTimer ) {
clearTimeout ( markdownSyncTimer )