@@ -12,6 +12,7 @@ import {
UndoOutlined ,
UnorderedListOutlined ,
} from "@ant-design/icons-vue" ;
import { message } from "ant-design-vue" ;
import { redoCommand , undoCommand } from "@milkdown/kit/plugin/history" ;
import {
insertHrCommand ,
@@ -30,13 +31,16 @@ import { Crepe, CrepeFeature } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css" ;
import "@milkdown/crepe/theme/frame.css" ;
import { Milkdown , useEditor , useInstance } from "@milkdown/vue" ;
import { computed , onBeforeUnmount , ref , watch } from "vue" ;
import { computed , onBeforeUnmount , onMounted , ref , watch } from "vue" ;
import { useI18n } from "vue-i18n" ;
import { formatError } from "@/lib/errors" ;
const props = defineProps < {
title : string ;
modelValue : string ;
disabled ? : boolean ;
uploadImage ? : ( file : File ) => Promise < string > ;
} > ( ) ;
const emit = defineEmits < {
@@ -47,6 +51,17 @@ const emit = defineEmits<{
const { t } = useI18n ( ) ;
const crepeRef = ref < Crepe | null > ( null ) ;
const syncingExternalMarkdown = ref ( false ) ;
const tablePickerRef = ref < HTMLElement | null > ( null ) ;
const tableGridRef = ref < HTMLElement | null > ( null ) ;
const imageInputRef = ref < HTMLInputElement | null > ( null ) ;
const TABLE _PICKER _CELL _SIZE = 22 ;
const TABLE _PICKER _GAP = 6 ;
const TABLE _PICKER _INITIAL _ROWS = 4 ;
const TABLE _PICKER _INITIAL _COLS = 10 ;
const TABLE _PICKER _MAX _ROWS = 15 ;
const TABLE _PICKER _MAX _COLS = 15 ;
const TABLE _PICKER _EXPAND _STEP = 2 ;
const { loading } = useEditor ( ( root ) => {
const crepe = new Crepe ( {
@@ -78,6 +93,37 @@ const { loading } = useEditor((root) => {
const [ instanceLoading , getEditor ] = useInstance ( ) ;
const editorDisabled = computed ( ( ) => props . disabled || instanceLoading . value || loading . value ) ;
const tablePickerOpen = ref ( false ) ;
const tablePickerRows = ref ( TABLE _PICKER _INITIAL _ROWS ) ;
const tablePickerCols = ref ( TABLE _PICKER _INITIAL _COLS ) ;
const tablePreviewRow = ref ( 0 ) ;
const tablePreviewCol = ref ( 0 ) ;
const tablePointerActive = ref ( false ) ;
const imageUploading = ref ( false ) ;
const tablePickerGridStyle = computed ( ( ) => ( {
gridTemplateColumns : ` repeat( ${ tablePickerCols . value } , ${ TABLE _PICKER _CELL _SIZE } px) ` ,
} ) ) ;
const tablePickerCells = computed ( ( ) => {
const cells : Array < { key : string ; row : number ; col : number } > = [ ] ;
for ( let row = 1 ; row <= tablePickerRows . value ; row += 1 ) {
for ( let col = 1 ; col <= tablePickerCols . value ; col += 1 ) {
cells . push ( { key : ` ${ row } - ${ col } ` , row , col } ) ;
}
}
return cells ;
} ) ;
const tableSelectionLabel = computed ( ( ) => {
if ( ! tablePreviewRow . value || ! tablePreviewCol . value ) {
return t ( "article.editor.tablePicker.empty" ) ;
}
return ` ${ tablePreviewRow . value } × ${ tablePreviewCol . value } ` ;
} ) ;
function syncMarkdownFromProps ( nextValue : string ) : void {
const editor = getEditor ( ) ;
@@ -102,6 +148,9 @@ watch(
( ) => props . disabled ,
( value ) => {
crepeRef . value ? . setReadonly ( ! ! value ) ;
if ( value ) {
closeTablePicker ( ) ;
}
} ,
{ immediate : true } ,
) ;
@@ -119,9 +168,18 @@ watch(
) ;
onBeforeUnmount ( ( ) => {
window . removeEventListener ( "pointerdown" , handleWindowPointerDown ) ;
window . removeEventListener ( "pointermove" , handleWindowPointerMove ) ;
window . removeEventListener ( "pointerup" , handleWindowPointerUp ) ;
crepeRef . value = null ;
} ) ;
onMounted ( ( ) => {
window . addEventListener ( "pointerdown" , handleWindowPointerDown ) ;
window . addEventListener ( "pointermove" , handleWindowPointerMove ) ;
window . addEventListener ( "pointerup" , handleWindowPointerUp ) ;
} ) ;
function runCommand ( command : { key : unknown } , payload ? : unknown ) : void {
if ( editorDisabled . value ) {
return ;
@@ -140,6 +198,11 @@ function insertImage(): void {
return ;
}
if ( props . uploadImage ) {
imageInputRef . value ? . click ( ) ;
return ;
}
const src = window . prompt ( t ( "article.editor.imagePrompt" ) ) ;
if ( ! src ? . trim ( ) ) {
return ;
@@ -148,8 +211,167 @@ function insertImage(): void {
runCommand ( insertImageCommand , { src : src . trim ( ) } ) ;
}
function insertTable ( ) : void {
runCommand ( insertTableCommand , { row : 3 , col : 3 } ) ;
async function handleImageFileChange ( event : Event ) : Promise < void > {
const input = event . target as HTMLInputElement ;
const file = input . files ? . [ 0 ] ;
input . value = "" ;
if ( ! file || ! props . uploadImage || editorDisabled . value || imageUploading . value ) {
return ;
}
imageUploading . value = true ;
try {
const src = ( await props . uploadImage ( file ) ) . trim ( ) ;
if ( ! src ) {
throw new Error ( "article_image_url_unavailable" ) ;
}
runCommand ( insertImageCommand , {
src ,
alt : resolveImageAltText ( file . name ) ,
} ) ;
} catch ( error ) {
message . error ( formatError ( error ) ) ;
} finally {
imageUploading . value = false ;
}
}
function resolveImageAltText ( fileName : string ) : string {
return fileName . replace ( /\.[^.]+$/ , "" ) . trim ( ) || "image" ;
}
function clamp ( value : number , min : number , max : number ) : number {
return Math . min ( Math . max ( value , min ) , max ) ;
}
function resetTablePicker ( ) : void {
tablePickerRows . value = TABLE _PICKER _INITIAL _ROWS ;
tablePickerCols . value = TABLE _PICKER _INITIAL _COLS ;
tablePreviewRow . value = 0 ;
tablePreviewCol . value = 0 ;
tablePointerActive . value = false ;
}
function closeTablePicker ( ) : void {
tablePickerOpen . value = false ;
resetTablePicker ( ) ;
}
function ensureTablePickerCapacity ( row : number , col : number ) : void {
while ( row > tablePickerRows . value && tablePickerRows . value < TABLE _PICKER _MAX _ROWS ) {
tablePickerRows . value = Math . min ( TABLE _PICKER _MAX _ROWS , tablePickerRows . value + TABLE _PICKER _EXPAND _STEP ) ;
}
while ( col > tablePickerCols . value && tablePickerCols . value < TABLE _PICKER _MAX _COLS ) {
tablePickerCols . value = Math . min ( TABLE _PICKER _MAX _COLS , tablePickerCols . value + TABLE _PICKER _EXPAND _STEP ) ;
}
if ( row === tablePickerRows . value && tablePickerRows . value < TABLE _PICKER _MAX _ROWS ) {
tablePickerRows . value = Math . min ( TABLE _PICKER _MAX _ROWS , tablePickerRows . value + TABLE _PICKER _EXPAND _STEP ) ;
}
if ( col === tablePickerCols . value && tablePickerCols . value < TABLE _PICKER _MAX _COLS ) {
tablePickerCols . value = Math . min ( TABLE _PICKER _MAX _COLS , tablePickerCols . value + TABLE _PICKER _EXPAND _STEP ) ;
}
}
function updateTablePreview ( row : number , col : number ) : void {
const nextRow = clamp ( row , 1 , TABLE _PICKER _MAX _ROWS ) ;
const nextCol = clamp ( col , 1 , TABLE _PICKER _MAX _COLS ) ;
ensureTablePickerCapacity ( nextRow , nextCol ) ;
tablePreviewRow . value = nextRow ;
tablePreviewCol . value = nextCol ;
}
function resolveTableSizeFromPointer ( clientX : number , clientY : number ) : { row : number ; col : number } | null {
const grid = tableGridRef . value ;
if ( ! grid ) {
return null ;
}
const rect = grid . getBoundingClientRect ( ) ;
const stepX = TABLE _PICKER _CELL _SIZE + TABLE _PICKER _GAP ;
const stepY = TABLE _PICKER _CELL _SIZE + TABLE _PICKER _GAP ;
const relativeX = clientX - rect . left ;
const relativeY = clientY - rect . top ;
return {
row : clamp ( Math . ceil ( ( relativeY + TABLE _PICKER _GAP ) / stepY ) , 1 , TABLE _PICKER _MAX _ROWS ) ,
col : clamp ( Math . ceil ( ( relativeX + TABLE _PICKER _GAP ) / stepX ) , 1 , TABLE _PICKER _MAX _COLS ) ,
} ;
}
function insertTableFromSelection ( row : number , col : number ) : void {
runCommand ( insertTableCommand , { row , col } ) ;
closeTablePicker ( ) ;
}
function toggleTablePicker ( ) : void {
if ( editorDisabled . value ) {
return ;
}
if ( tablePickerOpen . value ) {
closeTablePicker ( ) ;
return ;
}
resetTablePicker ( ) ;
tablePickerOpen . value = true ;
}
function beginTableSelection ( row : number , col : number ) : void {
if ( editorDisabled . value ) {
return ;
}
tablePointerActive . value = true ;
updateTablePreview ( row , col ) ;
}
function handleWindowPointerDown ( event : PointerEvent ) : void {
if ( ! tablePickerOpen . value ) {
return ;
}
const target = event . target ;
if ( target instanceof Node && tablePickerRef . value ? . contains ( target ) ) {
return ;
}
closeTablePicker ( ) ;
}
function handleWindowPointerMove ( event : PointerEvent ) : void {
if ( ! tablePickerOpen . value || ! tablePointerActive . value ) {
return ;
}
const nextSize = resolveTableSizeFromPointer ( event . clientX , event . clientY ) ;
if ( ! nextSize ) {
return ;
}
updateTablePreview ( nextSize . row , nextSize . col ) ;
}
function handleWindowPointerUp ( ) : void {
if ( ! tablePointerActive . value ) {
return ;
}
tablePointerActive . value = false ;
if ( ! tablePreviewRow . value || ! tablePreviewCol . value ) {
closeTablePicker ( ) ;
return ;
}
insertTableFromSelection ( tablePreviewRow . value , tablePreviewCol . value ) ;
}
< / script >
@@ -190,12 +412,58 @@ function insertTable(): void {
< a-button size = "small" type = "text" @click ="runCommand(wrapInOrderedListCommand)" >
< template # icon > < OrderedListOutlined / > < / template >
< / a-button >
< a-button size = "small " type = "text" @click ="insertTable ">
< template # icon > < TableOutlined / > < / template >
< / a-button >
< div ref = "tablePickerRef " class = "article-editor-canvas__table-trigger " >
< a-button
size = "small"
type = "text"
class = "article-editor-canvas__toolbar-button"
: class = "{ 'article-editor-canvas__toolbar-button--active': tablePickerOpen }"
@click ="toggleTablePicker"
>
< template # icon > < TableOutlined / > < / template >
< / a-button >
< div v-if = "tablePickerOpen" class="article-editor-canvas__table-panel" @pointerdown.stop >
< div class = "article-editor-canvas__table-panel-status" > { { tableSelectionLabel } } < / div >
< div
ref = "tableGridRef"
class = "article-editor-canvas__table-grid"
:style = "tablePickerGridStyle"
>
< button
v-for = "cell in tablePickerCells"
:key = "cell.key"
type = "button"
class = "article-editor-canvas__table-cell"
: class = "{ 'article-editor-canvas__table-cell--active': cell.row <= tablePreviewRow && cell.col <= tablePreviewCol }"
@mouseenter ="updateTablePreview(cell.row, cell.col)"
@focus ="updateTablePreview(cell.row, cell.col)"
@pointerdown.prevent.stop ="beginTableSelection(cell.row, cell.col)"
@keydown.enter.prevent ="insertTableFromSelection(cell.row, cell.col)"
@keydown.space.prevent ="insertTableFromSelection(cell.row, cell.col)"
> < / button >
< / div >
< div class = "article-editor-canvas__table-panel-hint" > { { t ( "article.editor.tablePicker.hint" ) } } < / div >
< / div >
< / div >
< span class = "article-editor-canvas__divider" > < / span >
< a-button size = "small" type = "text" @click ="insertImage" >
< input
ref = "imageInputRef"
type = "file"
accept = "image/png,image/jpeg,image/gif,image/webp"
hidden
@change ="handleImageFileChange"
/ >
< a-button
size = "small"
type = "text"
:loading = "imageUploading"
:disabled = "imageUploading"
@mousedown.prevent
@click ="insertImage"
>
< template # icon > < PictureOutlined / > < / template >
< / a-button >
< / div >
@@ -241,6 +509,8 @@ function insertTable(): void {
}
. article - editor - canvas _ _toolbar {
position : relative ;
z - index : 3 ;
display : flex ;
flex - wrap : wrap ;
align - items : center ;
@@ -258,8 +528,86 @@ function insertTable(): void {
background : # e5ebf5 ;
}
. article - editor - canvas _ _table - trigger {
position : relative ;
display : flex ;
align - items : center ;
}
. article - editor - canvas _ _toolbar : deep ( . article - editor - canvas _ _toolbar - button -- active ) {
color : # 245 bdb ;
background : rgba ( 47 , 107 , 255 , 0.1 ) ;
}
. article - editor - canvas _ _table - panel {
position : absolute ;
top : calc ( 100 % + 12 px ) ;
left : - 18 px ;
z - index : 24 ;
width : max - content ;
max - width : min ( calc ( 100 vw - 48 px ) , 520 px ) ;
padding : 18 px ;
border : 1 px solid # e3e9f4 ;
border - radius : 20 px ;
background :
linear - gradient ( 180 deg , rgba ( 255 , 255 , 255 , 0.98 ) 0 % , rgba ( 248 , 250 , 255 , 0.98 ) 100 % ) ;
box - shadow :
0 18 px 40 px rgba ( 15 , 23 , 42 , 0.14 ) ,
0 2 px 8 px rgba ( 15 , 23 , 42 , 0.06 ) ;
backdrop - filter : blur ( 16 px ) ;
}
. article - editor - canvas _ _table - panel - status {
margin - bottom : 14 px ;
color : # 101828 ;
font - size : 14 px ;
font - weight : 700 ;
text - align : center ;
}
. article - editor - canvas _ _table - grid {
display : grid ;
gap : 6 px ;
justify - content : start ;
}
. article - editor - canvas _ _table - cell {
width : 22 px ;
height : 22 px ;
padding : 0 ;
border : 1 px solid # d5dbe7 ;
border - radius : 4 px ;
background : # ffffff ;
cursor : crosshair ;
transition :
border - color 0.14 s ease ,
background - color 0.14 s ease ,
transform 0.14 s ease ,
box - shadow 0.14 s ease ;
}
. article - editor - canvas _ _table - cell : hover ,
. article - editor - canvas _ _table - cell -- active {
border - color : # 2 f6bff ;
background : rgba ( 47 , 107 , 255 , 0.14 ) ;
box - shadow : inset 0 0 0 1 px rgba ( 47 , 107 , 255 , 0.08 ) ;
}
. article - editor - canvas _ _table - cell : focus - visible {
outline : 2 px solid rgba ( 47 , 107 , 255 , 0.36 ) ;
outline - offset : 2 px ;
}
. article - editor - canvas _ _table - panel - hint {
margin - top : 12 px ;
color : # 667085 ;
font - size : 12 px ;
line - height : 1.4 ;
}
. article - editor - canvas _ _surface {
position : relative ;
z - index : 1 ;
flex : 1 ;
min - height : 0 ;
overflow - y : auto ;
@@ -267,7 +615,7 @@ function insertTable(): void {
. article - editor - canvas _ _title - row {
position : relative ;
z - index : 1 ;
z - index : 0 ;
padding : 18 px 28 px 0 ;
}