Skip to content

Commit

Permalink
feat: block image & image set size
Browse files Browse the repository at this point in the history
  • Loading branch information
Seedsa committed Sep 27, 2024
1 parent 6cfbf21 commit d6880ef
Show file tree
Hide file tree
Showing 10 changed files with 294 additions and 225 deletions.
2 changes: 2 additions & 0 deletions src/components/EchoEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import LinkBubbleMenu from './menus/LinkBubbleMenu.vue'
import TableBubbleMenu from './menus/TableBubbleMenu.vue'
import ContentMenu from './menus/ContentMenu.vue'
import ColumnsBubbleMenu from './menus/ColumnsBubbleMenu.vue'
import ImageBubbleMenu from './menus/ImageBubbleMenu.vue'
import AIMenu from './menus/AIMenu.vue'
import Menubars from './Menubars.vue'
import Toolbar from './Toolbar.vue'
Expand Down Expand Up @@ -176,6 +177,7 @@ defineExpose({ editor })
<TableBubbleMenu :editor="editor" />
<AIMenu :editor="editor" :disabled="disabled" />
<BasicBubbleMenu v-if="!hideBubble" :editor="editor" :disabled="disableBubble" />
<ImageBubbleMenu :editor="editor" :disabled="disableBubble" />
<Preview :editor="editor" />
<Printer :editor="editor" />
<FindAndReplace :container-ref="contentRef" :editor="editor" />
Expand Down
1 change: 1 addition & 0 deletions src/components/icons/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const icons = {
SpellCheck: 'lucide:spell-check-2',
DocSearch: 'fluent:document-search-24-regular',
Close: 'lucide:x',
ImageSize: 'fluent:resize-image-24-filled',
}

export default icons
50 changes: 2 additions & 48 deletions src/components/menus/BasicBubble.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import { deleteSelection } from '@tiptap/pm/commands'
import type { Editor } from '@tiptap/vue-3'
import ActionButton from '../ActionButton.vue'
import { IMAGE_SIZE, VIDEO_SIZE } from '@/constants'
import { VIDEO_SIZE } from '@/constants'
import type { ButtonViewParams, ButtonViewReturn, ExtensionNameKeys } from '@/type'
import { useLocale } from '@/locales'
/** Represents the size types for bubble images or videos */
type BubbleImageOrVideoSizeType = 'size-small' | 'size-medium' | 'size-large'
type ImageAlignments = 'left' | 'center' | 'right'

/** Represents the various types for bubble images */
type BubbleImageType =
| `image-${BubbleImageOrVideoSizeType}`
| `video-${BubbleImageOrVideoSizeType}`
| 'image'
| 'image-aspect-ratio'
| 'remove'
type BubbleImageType = `video-${BubbleImageOrVideoSizeType}` | 'image' | 'image-aspect-ratio' | 'remove'

/** Represents the types for bubble videos */
type BubbleVideoType = 'video' | 'remove'
Expand Down Expand Up @@ -65,43 +58,6 @@ export interface BubbleOptions<T> {
button: BubbleView<T>
}

const imageSizeMenus = (editor: Editor): BubbleMenuItem[] => {
const types: BubbleImageOrVideoSizeType[] = ['size-small', 'size-medium', 'size-large']
const icons: NonNullable<ButtonViewReturn['componentProps']['icon']>[] = ['SizeS', 'SizeM', 'SizeL']

return types.map((size, i) => ({
type: `image-${size}`,
component: ActionButton,
componentProps: {
tooltip: `editor.${size.replace('-', '.')}.tooltip`,
icon: icons[i],
action: () => editor.commands.updateImage({ width: IMAGE_SIZE[size] }),
isActive: () => editor.isActive('image', { width: IMAGE_SIZE[size] }),
},
}))
}

const imageAlignMenus = (editor: Editor): BubbleMenuItem[] => {
const { t } = useLocale()
const types: ImageAlignments[] = ['left', 'center', 'right']
const iconMap: any = {
left: 'AlignLeft',
center: 'AlignCenter',
right: 'AlignRight',
}
return types.map((k, i) => ({
type: `image-${k}`,
component: ActionButton,
componentProps: {
tooltip: t.value(`editor.textalign.${k}.tooltip`),
icon: iconMap[k],
action: () => editor.commands.setTextAlign(k),
isActive: () => editor.isActive({ textAlign: k }) || false,
disabled: !editor.can().setTextAlign(k),
},
}))
}

// 视频尺寸菜单
const videoSizeMenus = (editor: Editor): BubbleMenuItem[] => {
const types: BubbleImageOrVideoSizeType[] = ['size-small', 'size-medium', 'size-large']
Expand All @@ -119,9 +75,7 @@ const videoSizeMenus = (editor: Editor): BubbleMenuItem[] => {
}))
}
export const defaultBubbleList = (editor: Editor): BubbleMenuItem[] => [
...imageSizeMenus(editor),
...videoSizeMenus(editor),
...imageAlignMenus(editor),
{
type: 'remove',
component: ActionButton,
Expand Down
2 changes: 0 additions & 2 deletions src/components/menus/BasicBubbleMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,10 @@ const tippyOptions = reactive<Record<string, unknown>>({
const nodeType = computed(() => {
const selection = props.editor.state.selection as NodeSelection
const isImage = selection.node?.type.name === 'image'
const isLink = props.editor.isActive('link')
const isVideo = selection.node?.type.name === 'video'
const isText = selection instanceof TextSelection
if (isLink) return 'link'
if (isImage) return 'image'
if (isVideo) return 'video'
if (isText) return 'text'
return undefined
Expand Down
184 changes: 184 additions & 0 deletions src/components/menus/ImageBubbleMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<script lang="ts" setup>
import type { Editor } from '@tiptap/vue-3'
import { BubbleMenu, isActive } from '@tiptap/vue-3'
import { Instance, sticky } from 'tippy.js'
import { getRenderContainer } from '@/utils/getRenderContainer'
import { useLocale } from '@/locales'
import { deleteSelection } from '@tiptap/pm/commands'
interface Props {
editor: Editor
disabled?: boolean
}
type ImageAlignments = 'left' | 'center' | 'right'
const props = withDefaults(defineProps<Props>(), {
disabled: false,
})
const { t } = useLocale()
const imagePercent = ref('100')
const width = ref()
const height = ref()
const aspectRatio = ref()
const imageAlign: ImageAlignments[] = ['left', 'center', 'right']
const alignIconMap: any = {
left: 'AlignLeft',
center: 'AlignCenter',
right: 'AlignRight',
}
function updateImageSize(event?: Event) {
event?.preventDefault()
const imageAttrs = props.editor.getAttributes('image')
if (imageAttrs.src) {
props.editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateImage({
width: width.value ? `${width.value}px` : null,
})
.run()
}
}
function changeImagePercent(event?: any) {
event?.preventDefault()
const percent = Math.max(0, Math.min(100, parseInt(imagePercent.value)))
props.editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateImage({ width: `${percent}%` })
.run()
}
const shouldShow = ({ editor }) => isActive(editor.view.state, 'image')
const getReferenceClientRect = computed(() => {
const renderContainer = getRenderContainer(props.editor, 'node-image')
return renderContainer?.getBoundingClientRect() || new DOMRect(-1000, -1000, 0, 0)
})
function setImageAlign(align: ImageAlignments) {
props.editor.chain().focus().setTextAlign(align).run()
}
watch(imagePercent, () => {
if (imagePercent.value) {
changeImagePercent()
}
})
watch(
() => props.editor.getAttributes('image'),
image => {
if (image) {
width.value = Math.round(parseFloat(image.originWidth))
height.value = Math.round(parseFloat(image.originHeight))
aspectRatio.value = image.originWidth / image.originHeight
}
}
)
function updateWidthFromHeight() {
if (height.value && aspectRatio.value) {
width.value = Math.max(30, Math.round(height.value * aspectRatio.value))
} else {
width.value = null
}
}
function updateHeightFromWidth() {
if (width.value && aspectRatio.value) {
height.value = Math.max(20, Math.round(width.value / aspectRatio.value))
} else {
height.value = null
}
}
function handleSetImageAlign(align: ImageAlignments) {
setImageAlign(align)
}
function handleRemove() {
const { state, dispatch } = props.editor.view
deleteSelection(state, dispatch)
}
</script>
<template>
<BubbleMenu
:editor="editor"
pluginKey="image-menus-123"
:shouldShow="shouldShow"
:updateDelay="0"
:tippy-options="{
offset: [0, 8],
zIndex: 10,
popperOptions: {
modifiers: [{ name: 'flip', enabled: false }],
},
getReferenceClientRect: getReferenceClientRect.value,
plugins: [sticky],
sticky: 'popper',
}"
>
<div
class="border border-neutral-200 dark:border-neutral-800 px-3 py-2 transition-all select-none pointer-events-auto shadow-sm rounded-sm w-auto bg-background"
>
<div class="flex items-center flex-nowrap whitespace-nowrap h-[26px] justify-start relative gap-0.5">
<Popover>
<PopoverTrigger>
<ActionButton :title="t('editor.image.menu.size')" icon="ImageSize" />
</PopoverTrigger>
<PopoverContent class="w-84">
<div class="flex items-center gap-2">
<Label for="maxWidth" class="whitespace-nowrap">{{ t('editor.image.menu.size.width') }}</Label>
<Input
id="maxWidth"
v-model="width"
type="number"
@input="updateHeightFromWidth"
@keyup.enter="updateImageSize"
class="w-20 h-8 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<Label for="maxWidth" class="whitespace-nowrap">{{ t('editor.image.menu.size.height') }}</Label>
<Input
id="maxWidth"
v-model="height"
type="number"
@input="updateWidthFromHeight"
@keyup.enter="updateImageSize"
class="w-20 h-8 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
<div class="mt-3">
<Tabs
v-model:model-value="imagePercent"
@update:model-value="
value => {
imagePercent = value as string
}
"
>
<TabsList>
<TabsTrigger v-for="value in ['25', '50', '75', '100']" :key="value" :value="value">
{{ value }}%
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</PopoverContent>
</Popover>
<Separator orientation="vertical" class="mx-1 me-2 h-[16px]" />
<ActionButton
v-for="(item, index) in imageAlign"
:key="index"
:tooltip="t(`editor.textalign.${item}.tooltip`)"
:icon="alignIconMap[item]"
:action="() => handleSetImageAlign(item)"
:disabled="!editor.can().setTextAlign(item)"
:is-active="() => editor.isActive({ textAlign: item }) || false"
/>
<Separator orientation="vertical" class="mx-1 me-2 h-[16px]" />
<ActionButton
:tooltip="t('editor.remove')"
icon="Trash2"
:action="handleRemove"
:disabled="!editor.isEditable"
/>
</div>
</div>
</BubbleMenu>
</template>
20 changes: 0 additions & 20 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,6 @@ export const DEFAULT_FONT_SIZE_LIST = [
/** Default font size value */
export const DEFAULT_FONT_SIZE_VALUE = 'defaut' as const

/** Options for setting image size in the bubble menu */
export enum IMAGE_SIZE {
'size-small' = 200,
'size-medium' = 500,
'size-large' = '100%',
}

/** Options for setting video size in the bubble menu */
export enum VIDEO_SIZE {
'size-small' = 480,
Expand All @@ -139,19 +132,6 @@ export const DEFAULT_LINE_HEIGHT = '1'

/** display in menus */
export const NODE_TYPE_MENU: any = {
image: [
'divider',
'image-size-small',
'image-size-medium',
'image-size-large',
'divider',
'image-left',
'image-center',
'image-right',
'divider',
'image-aspect-ratio',
'remove',
],
text: [
'AI',
'divider',
Expand Down
14 changes: 11 additions & 3 deletions src/extensions/Image/Image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ declare module '@tiptap/core' {
}
}
export const Image = TiptapImage.extend({
group: 'block',
defining: true,
isolating: true,
addAttributes() {
return {
...this.parent?.(),
width: {
originWidth: {
default: null,
},
originHeight: {
default: null,
},
width: {
default: '100%',
parseHTML: element => {
const width = element.style.width || element.getAttribute('width') || null
if (width && width.endsWith('%')) {
Expand Down Expand Up @@ -62,7 +71,7 @@ export const Image = TiptapImage.extend({
...this.parent?.(),
updateImage:
options =>
({ commands }) => {
({ commands, editor }) => {
return commands.updateAttributes(this.name, options)
},
}
Expand All @@ -79,7 +88,6 @@ export const Image = TiptapImage.extend({
return [
'img',
mergeAttributes(
// Always render the `height="auto"
{
height: 'auto',
style,
Expand Down
Loading

0 comments on commit d6880ef

Please sign in to comment.