Просмотр исходного кода

feat: 新增文本样式、图形翻转功能,补充多语言文案与删除确认弹窗

1.  新增TextColorButton、ElementFlip2、RichTextBase2组件
2.  新增多语言文本样式相关文案
3.  为幻灯片删除添加确认弹窗
4.  新增图形填充样式设置面板
5.  修复部分样式与国际化引入问题
6.  临时隐藏添加幻灯片按钮,新增页间添加按钮
lsc 5 дней назад
Родитель
Сommit
fb63b3d4f8

+ 1 - 0
src/components/ColorButton.vue

@@ -33,6 +33,7 @@ defineProps<{
 .content {
   width: 100%;
   height: 100%;
+  padding: 0;
 }
 .color-btn-icon {
   width: 32px;

+ 38 - 0
src/components/TextColorButton2.vue

@@ -0,0 +1,38 @@
+<template>
+  <Button class="text-color-btn">
+    <slot></slot>
+    <div class="text-color-block">
+      <div class="text-color-block-content" :style="{ backgroundColor: color }"></div>
+    </div>
+  </Button>
+</template>
+
+<script lang="ts" setup>
+import Button from './Button.vue'
+
+defineProps<{
+  color: string
+}>()
+</script>
+
+<style lang="scss" scoped>
+.text-color-btn {
+  width: 100%;
+  display: flex !important;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 0 15px;
+}
+.text-color-block {
+  width: 17px;
+  height: 4px;
+  margin-top: 1px;
+  background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAACdJREFUGFdjfPbs2X8GBgYGSUlJEMXAiCHw//9/sIrnz59DVKALAADNxxVfaiODNQAAAABJRU5ErkJggg==);
+
+  .text-color-block-content {
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 4 - 0
src/utils/confirmDialog.ts

@@ -8,6 +8,8 @@ interface ConfirmDialogOptions {
   confirmText?: string
   cancelText?: string
   width?: number
+  onConfirm?: () => void
+  onCancel?: () => void
 }
 
 export function showConfirmDialog(options: ConfirmDialogOptions): Promise<boolean> {
@@ -27,6 +29,7 @@ export function showConfirmDialog(options: ConfirmDialogOptions): Promise<boolea
         visible.value = false
         setTimeout(() => {
           app.unmount()
+          options.onConfirm?.()
           document.body.removeChild(container)
           resolve(true)
         }, 300)
@@ -35,6 +38,7 @@ export function showConfirmDialog(options: ConfirmDialogOptions): Promise<boolea
         visible.value = false
         setTimeout(() => {
           app.unmount()
+          options.onCancel?.()
           document.body.removeChild(container)
           resolve(false)
         }, 300)

+ 166 - 1
src/views/Editor/CanvasTool/index2.vue

@@ -126,6 +126,73 @@
           <span>{{ lang.ssShape }}</span>
         </div>
       </Popover>
+      <RichTextBase  v-if="handleElement?.type == 'text'"/>
+      <div style="display: flex; align-items: center;" v-if="handleElement?.type == 'shape'">
+        <Select 
+          style="flex: 1;" 
+          :value="fillType" 
+          @update:value="value => updateFillType(value as 'fill' | 'gradient' | 'pattern')"
+          :options="[
+            { label: lang.ssSolidFill, value: 'fill' },
+            { label: lang.ssGradFill, value: 'gradient' },
+            // { label: lang.ssImgFill, value: 'pattern' },
+          ]"
+        />
+        <Popover trigger="click" v-if="fillType === 'fill'" style="flex: 1;">
+          <template #content>
+            <ColorPicker
+              :modelValue="fill"
+              @update:modelValue="value => updateFill(value)"
+            />
+          </template>
+          <ColorButton style="width: 100px;" :color="fill" />
+        </Popover>
+        <Select 
+          style="flex: 1;" 
+          :value="gradient.type" 
+          @update:value="value => updateGradient({ type: value as GradientType })"
+          v-else-if="fillType === 'gradient'"
+          :options="[
+            { label: lang.ssLinearGrad, value: 'linear' },
+            { label: lang.ssRadialGrad, value: 'radial' },
+          ]"
+        />
+      </div>
+
+      <template v-if="fillType === 'gradient' && handleElement?.type == 'shape'">
+        <div class="row">
+          <GradientBar
+            :value="gradient.colors"
+            :index="currentGradientIndex"
+            @update:value="value => updateGradient({ colors: value })"
+            @update:index="index => currentGradientIndex = index"
+          />
+        </div>
+        <div class="row">
+          <div style="width: 40%;">{{ lang.ssCurColorBlock }}</div>
+          <Popover trigger="click" style="width: 60%;">
+            <template #content>
+              <ColorPicker
+                :modelValue="gradient.colors[currentGradientIndex].color"
+                @update:modelValue="value => updateGradientColors(value)"
+              />
+            </template>
+            <ColorButton :color="gradient.colors[currentGradientIndex].color" />
+          </Popover>
+        </div>
+        <div class="row" v-if="gradient.type === 'linear'">
+          <div style="width: 40%;">{{ lang.ssGradAngle }}</div>
+          <Slider
+            style="width: 60%;"
+            :min="0"
+            :max="360"
+            :step="15"
+            :value="gradient.rotate"
+            @update:value="value => updateGradient({ rotate: value as number })" 
+          />
+        </div>
+      </template>
+      <ElementFlip  v-if="handleElement?.type == 'shape'"/>
 
       <!-- 英语口语工具:重置预览 -->
       <div
@@ -166,7 +233,7 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, computed } from 'vue'
+import { ref, computed, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSnapshotStore, useSlidesStore } from '@/store'
 import { useSpeakingStore } from '@/store/speaking'
@@ -193,6 +260,14 @@ import Popover from '@/components/Popover.vue'
 import PopoverMenuItem from '@/components/PopoverMenuItem.vue'
 import Button from '@/components/Button.vue'
 
+import RichTextBase from '@/views/Editor/Toolbar/common/RichTextBase2.vue'
+import Select from '@/components/Select.vue'
+import ColorPicker from '@/components/ColorPicker/index.vue'
+import GradientBar from '@/components/GradientBar.vue'
+import Slider from '@/components/Slider.vue'
+import ColorButton from '@/components/ColorButton.vue'
+import ElementFlip from '@/views/Editor/Toolbar/common/ElementFlip2.vue'
+
 const mainStore = useMainStore()
 const slidesStore = useSlidesStore()
 const speakingStore = useSpeakingStore()
@@ -459,6 +534,89 @@ const editContent = (toolType: number) => {
   })
 }
 
+import type { GradientType, PPTShapeElement, Gradient, ShapeText } from '@/types/slides'
+const { handleElement, handleElementId } = storeToRefs(useMainStore())
+const fillType = ref('fill')
+const fill = ref<string>('#000')
+const textAlign = ref('middle')
+const pattern = ref('')
+import emitter, { EmitterEvents } from '@/utils/emitter'
+const { addHistorySnapshot } = useHistorySnapshot()
+const currentGradientIndex = ref(0)
+watch(handleElementId, () => {
+  currentGradientIndex.value = 0
+})
+
+const updateElement = (props: Partial<PPTShapeElement>) => {
+  slidesStore.updateElement({ id: handleElementId.value, props })
+  addHistorySnapshot()
+}
+
+const gradient = ref<Gradient>({
+  type: 'linear', 
+  rotate: 0,
+  colors: [
+    { pos: 0, color: '#fff' },
+    { pos: 100, color: '#fff' },
+  ],
+})
+
+watch(handleElement, () => {
+  if (!handleElement.value || handleElement.value.type !== 'shape') return
+
+  fill.value = handleElement.value.fill || '#fff'
+  const defaultGradientColor = [
+    { pos: 0, color: fill.value },
+    { pos: 100, color: '#fff' },
+  ]
+  gradient.value = handleElement.value.gradient || { type: 'linear', rotate: 0, colors: defaultGradientColor }
+  pattern.value = handleElement.value.pattern || ''
+  fillType.value = (handleElement.value.pattern !== undefined) ? 'pattern' : (handleElement.value.gradient ? 'gradient' : 'fill')
+  textAlign.value = handleElement.value?.text?.align || 'middle'
+
+  if (handleElement.value.text?.content) {
+    emitter.emit(EmitterEvents.SYNC_RICH_TEXT_ATTRS_TO_STORE)
+  }
+}, { deep: true, immediate: true })
+
+// 设置填充类型:渐变、纯色
+const updateFillType = (type: 'gradient' | 'fill' | 'pattern') => {
+  console.log('设置填充类型:', type)
+  if (type === 'fill') {
+    slidesStore.removeElementProps({ id: handleElementId.value, propName: ['gradient', 'pattern'] })
+    addHistorySnapshot()
+  }
+  else if (type === 'gradient') {
+    currentGradientIndex.value = 0
+    slidesStore.removeElementProps({ id: handleElementId.value, propName: 'pattern' })
+    updateElement({ gradient: gradient.value })
+  }
+  // else if (type === 'pattern') {
+  //   slidesStore.removeElementProps({ id: handleElementId.value, propName: 'gradient' })
+  //   updateElement({ pattern: '' })
+  // }
+}
+
+// 设置填充色
+const updateFill = (value: string) => {
+  updateElement({ fill: value })
+}
+
+// 设置渐变填充
+const updateGradient = (gradientProps: Partial<Gradient>) => {
+  if (!gradient.value) return
+  const _gradient = { ...gradient.value, ...gradientProps }
+  updateElement({ gradient: _gradient })
+}
+const updateGradientColors = (color: string) => {
+  const colors = gradient.value.colors.map((item, index) => {
+    if (index === currentGradientIndex.value) return { ...item, color }
+    return item
+  })
+  updateGradient({ colors })
+}
+
+
 </script>
 
 <style lang="scss" scoped>
@@ -695,4 +853,11 @@ const editContent = (toolType: number) => {
     }
   }
 }
+
+.row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 180px;
+}
 </style>

+ 57 - 2
src/views/Editor/Thumbnails/index2.vue

@@ -5,7 +5,7 @@
     v-click-outside="() => setThumbnailsFocus(false)"
     v-contextmenu="contextmenusThumbnails"
   >
-    <div class="add-slide">
+    <div class="add-slide" v-show="false">
       <div class="btn" @click="createSlide()"><IconPlus class="icon" /></div>
       <!-- 添加幻灯片 <Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
         <template #content>
@@ -71,6 +71,7 @@
 
             <div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
           </div>
+          <div class="add-page-between">+</div>
         </div>
       </template>
     </Draggable>
@@ -354,6 +355,24 @@ const showPageMenu = (event: MouseEvent, index: number) => {
   document.body.addEventListener('scroll', removeContextmenu)
   window.addEventListener('resize', removeContextmenu)
 }
+import { showConfirmDialog } from '@/utils/confirmDialog'
+
+const confirmDeleteSlide = () => {
+  showConfirmDialog({
+    title: lang.ssConfirmDel,
+    content: lang.ssConfirmDelContent,
+    confirmText: lang.ssConfirm,
+    cancelText: lang.ssCancel,
+    width: 400,
+    onConfirm: () => {
+      deleteSlide()
+    },
+    onCancel: () => {
+      console.log('取消删除')
+    },
+  })
+
+}
 
 const contextmenusThumbnailItem = (): ContextmenuItem[] => {
   return [
@@ -415,7 +434,7 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
     },
     {
       text: lang.ssDelPage2,
-      handler: () => deleteSlide(),
+      handler: confirmDeleteSlide,
     },
   ]
 }
@@ -478,6 +497,18 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
   display: flex;
   align-items: center;
   gap: 10px;
+
+  .thumbnail-container {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+
+    &:hover {
+      .add-page-between {
+        display: flex;
+      }
+    }
+  }
 }
 .thumbnail-item {
   display: flex;
@@ -647,4 +678,28 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
     font-size: 12px;
   }
 }
+
+.add-page-between {
+    width: 30px;
+    height: 30px;
+    border-radius: 50%;
+    border: 2px dashed #d1d5db;
+    background: white;
+    cursor: pointer;
+    display: none;
+    // align-items: center;
+    justify-content: center;
+    color: #9ca3af;
+    font-size: 20px;
+    transition: all 0.2s;
+    flex-shrink: 0;
+    line-height: 23px;
+
+    &:hover {
+      border-color: #F78B22;
+      border-style: solid;
+      color: #F78B22;
+      background: #FFF8F0;
+    }
+}
 </style>

+ 1 - 0
src/views/Editor/Toolbar/ElementStylePanel/index.vue

@@ -37,6 +37,7 @@ const panelMap = {
 const { handleElement } = storeToRefs(useMainStore())
 
 const currentPanelComponent = computed<unknown>(() => {
+  console.log('handleElement.value', handleElement.value)
   return handleElement.value ? (panelMap[handleElement.value.type] || null) : null
 })
 </script>

+ 59 - 0
src/views/Editor/Toolbar/common/ElementFlip2.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="element-flip">
+    <ButtonGroup class="row">
+      <CheckboxButton 
+        style="flex: 1;"
+        :checked="flipV"
+        @click="updateFlip({ flipV: !flipV })"
+      ><IconFlipVertically /> {{lang.flipVertically}}</CheckboxButton>
+      <CheckboxButton 
+        style="flex: 1;"
+        :checked="flipH"
+        @click="updateFlip({ flipH: !flipH })"
+      ><IconFlipHorizontally /> {{lang.flipHorizontally}}</CheckboxButton>
+    </ButtonGroup>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useMainStore, useSlidesStore } from '@/store'
+import type { ImageOrShapeFlip } from '@/types/slides'
+import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+
+import CheckboxButton from '@/components/CheckboxButton.vue'
+import ButtonGroup from '@/components/ButtonGroup.vue'
+
+import { lang } from '@/main'
+
+const slidesStore = useSlidesStore()
+const { handleElement } = storeToRefs(useMainStore())
+
+const flipH = ref(false)
+const flipV = ref(false)
+
+watch(handleElement, () => {
+  if (handleElement.value && (handleElement.value.type === 'image' || handleElement.value.type === 'shape')) {
+    flipH.value = !!handleElement.value.flipH
+    flipV.value = !!handleElement.value.flipV
+  }
+}, { deep: true, immediate: true })
+
+const { addHistorySnapshot } = useHistorySnapshot()
+
+const updateFlip = (flipProps: ImageOrShapeFlip) => {
+  if (!handleElement.value) return
+  slidesStore.updateElement({ id: handleElement.value.id, props: flipProps })
+  addHistorySnapshot()
+}
+</script>
+
+<style lang="scss" scoped>
+.row {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  // margin-bottom: 10px;
+}
+</style>

+ 476 - 0
src/views/Editor/Toolbar/common/RichTextBase2.vue

@@ -0,0 +1,476 @@
+<template>
+  <div class="rich-text-base">
+    <SelectGroup class="row">
+      <Select
+        style="width: 60%;"
+        :value="richTextAttrs.fontname"
+        search
+        :searchLabel="lang.searchFont"
+        @update:value="value => emitRichTextCommand('fontname', value as string)"
+        :options="FONTS"
+      >
+        <template #icon>
+          <IconFontSize />
+        </template>
+      </Select>
+      <Select
+        style="width: 40%;"
+        :value="richTextAttrs.fontsize"
+        search
+        :searchLabel="lang.searchFontSize"
+        @update:value="value => emitRichTextCommand('fontsize', value as string)"
+        :options="fontSizeOptions.map(item => ({
+          label: item, value: item
+        }))"
+      >
+        <template #icon>
+          <IconAddText />
+        </template>
+      </Select>
+    </SelectGroup>
+
+    <ButtonGroup class="row" passive>
+      <Popover trigger="click" style="width: 30%;">
+        <template #content>
+          <ColorPicker
+            :modelValue="richTextAttrs.color"
+            @update:modelValue="value => emitRichTextCommand('color', value)"
+          />
+        </template>
+        <TextColorButton first v-tooltip="lang.textColor" :color="richTextAttrs.color">
+          <IconText />
+        </TextColorButton>
+      </Popover>
+      <Popover trigger="click" style="width: 30%;">
+        <template #content>
+          <ColorPicker
+            :modelValue="richTextAttrs.backcolor"
+            @update:modelValue="value => emitRichTextCommand('backcolor', value)"
+          />
+        </template>
+        <TextColorButton v-tooltip="lang.highlight" :color="richTextAttrs.backcolor">
+          <IconHighLight />
+        </TextColorButton>
+      </Popover>
+      <Button 
+        style="display: flex;align-items: center;"
+        class="font-size-btn"
+        v-tooltip="lang.fontSizeAdd"
+        @click="emitRichTextCommand('fontsize-add')"
+      ><IconFontSize />+</Button>
+      <Button
+        style="display: flex;align-items: center;"
+        last
+        class="font-size-btn"
+        v-tooltip="lang.fontSizeReduce" 
+        @click="emitRichTextCommand('fontsize-reduce')"
+      ><IconFontSize />-</Button>
+    </ButtonGroup>
+
+    <ButtonGroup class="row">
+      <CheckboxButton 
+        style="flex: 1;"
+        :checked="richTextAttrs.bold"
+        v-tooltip="lang.bold"
+        @click="emitRichTextCommand('bold')"
+      ><IconTextBold /></CheckboxButton>
+      <CheckboxButton 
+        style="flex: 1;"
+        :checked="richTextAttrs.em"
+        v-tooltip="lang.italic"
+        @click="emitRichTextCommand('em')"
+      ><IconTextItalic /></CheckboxButton>
+      <CheckboxButton 
+        style="flex: 1;"
+        :checked="richTextAttrs.underline"
+        v-tooltip="lang.underline"
+        @click="emitRichTextCommand('underline')"
+      ><IconTextUnderline /></CheckboxButton>
+      <CheckboxButton 
+        style="flex: 1;"
+        :checked="richTextAttrs.strikethrough"
+        v-tooltip="lang.strikethrough"
+        @click="emitRichTextCommand('strikethrough')"
+      ><IconStrikethrough /></CheckboxButton>
+    </ButtonGroup>
+
+    <!-- <ButtonGroup class="row">
+      <CheckboxButton
+        style="flex: 1;"
+        :checked="richTextAttrs.superscript"
+        v-tooltip="'上标'"
+        @click="emitRichTextCommand('superscript')"
+      >A²</CheckboxButton>
+      <CheckboxButton
+        style="flex: 1;"
+        :checked="richTextAttrs.subscript"
+        v-tooltip="'下标'"
+        @click="emitRichTextCommand('subscript')"
+      >A₂</CheckboxButton>
+      <CheckboxButton
+        style="flex: 1;"
+        :checked="richTextAttrs.code"
+        v-tooltip="'行内代码'"
+        @click="emitRichTextCommand('code')"
+      ><IconCode /></CheckboxButton>
+      <CheckboxButton
+        style="flex: 1;"
+        :checked="richTextAttrs.blockquote"
+        v-tooltip="'引用'"
+        @click="emitRichTextCommand('blockquote')"
+      ><IconQuote /></CheckboxButton>
+    </ButtonGroup>
+
+    <ButtonGroup class="row" passive>
+      <Popover trigger="click" v-model:value="AIPopoverVisible" style="width: 25%;">
+        <template #content>
+          <PopoverMenuItem center @click="execAI('美化改写')">美化</PopoverMenuItem>
+          <PopoverMenuItem center @click="execAI('扩写丰富')">扩写</PopoverMenuItem>
+          <PopoverMenuItem center @click="execAI('精简提炼')">精简</PopoverMenuItem>
+        </template>
+        <CheckboxButton
+          first
+          style="width: 100%;"
+          v-tooltip="'AI辅助'"
+        ><span :class="{ 'ai-loading': isAIWriting }">{{ isAIWriting ? '' : 'AI' }}</span></CheckboxButton>
+      </Popover>
+      <CheckboxButton
+        style="flex: 1;"
+        v-tooltip="'清除格式'"
+        @click="emitRichTextCommand('clear')"
+      ><IconFormat /></CheckboxButton>
+      <CheckboxButton
+        style="flex: 1;"
+        :checked="!!textFormatPainter"
+        v-tooltip="'格式刷(双击连续使用)'"
+        @click="toggleTextFormatPainter()"
+        @dblclick="toggleTextFormatPainter(true)"
+      ><IconFormatBrush /></CheckboxButton>
+      <Popover placement="bottom-end" trigger="click" v-model:value="linkPopoverVisible" style="width: 25%;">
+        <template #content>
+          <div class="link-popover">
+            <Input v-model:value="link" placeholder="请输入超链接" />
+            <div class="btns">
+              <Button size="small" :disabled="!richTextAttrs.link" @click="removeLink()" style="margin-right: 5px;">移除</Button>
+              <Button size="small" type="primary" @click="updateLink(link)">确认</Button>
+            </div>
+          </div>
+        </template>
+        <CheckboxButton
+          last
+          style="width: 100%;"
+          :checked="!!richTextAttrs.link"
+          v-tooltip="'超链接'"
+          @click="openLinkPopover()"
+        ><IconLinkOne /></CheckboxButton>
+      </Popover>
+    </ButtonGroup>
+    <Divider />
+
+    <RadioGroup 
+      class="row" 
+      button-style="solid" 
+      :value="richTextAttrs.align"
+      @update:value="value => emitRichTextCommand('align', value)"
+    >
+      <RadioButton value="left" v-tooltip="'左对齐'" style="flex: 1;"><IconAlignTextLeft /></RadioButton>
+      <RadioButton value="center" v-tooltip="'居中'" style="flex: 1;"><IconAlignTextCenter /></RadioButton>
+      <RadioButton value="right" v-tooltip="'右对齐'" style="flex: 1;"><IconAlignTextRight /></RadioButton>
+      <RadioButton value="justify" v-tooltip="'两端对齐'" style="flex: 1;"><IconAlignTextBoth /></RadioButton>
+    </RadioGroup>
+
+    <div class="row" passive>
+      <ButtonGroup style="flex: 1;">
+        <Button
+          first
+          :type="richTextAttrs.bulletList ? 'primary' : 'default'"
+          style="flex: 1;"
+          v-tooltip="'项目符号'"
+          @click="emitRichTextCommand('bulletList')"
+        ><IconList /></Button>
+        <Popover trigger="click" v-model:value="bulletListPanelVisible">
+          <template #content>
+            <div class="list-wrap">
+              <ul class="list" 
+                v-for="item in bulletListStyleTypeOption" 
+                :key="item" 
+                :style="{ listStyleType: item }"
+                @click="emitRichTextCommand('bulletList', item)"
+              >
+                <li class="list-item" v-for="key in 3" :key="key"><span></span></li>
+              </ul>
+            </div>
+          </template>
+          <Button last class="popover-btn"><IconDown /></Button>
+        </Popover>
+      </ButtonGroup>
+      <div style="width: 10px;"></div>
+      <ButtonGroup style="flex: 1;" passive>
+        <Button
+          first
+          :type="richTextAttrs.orderedList ? 'primary' : 'default'"
+          style="flex: 1;"
+          v-tooltip="'编号'"
+          @click="emitRichTextCommand('orderedList')"
+        ><IconOrderedList /></Button>
+        <Popover trigger="click" v-model:value="orderedListPanelVisible">
+          <template #content>
+            <div class="list-wrap">
+              <ul class="list" 
+                v-for="item in orderedListStyleTypeOption" 
+                :key="item" 
+                :style="{ listStyleType: item }"
+                @click="emitRichTextCommand('orderedList', item)"
+              >
+                <li class="list-item" v-for="key in 3" :key="key"><span></span></li>
+              </ul>
+            </div>
+          </template>
+          <Button last class="popover-btn"><IconDown /></Button>
+        </Popover>
+      </ButtonGroup>
+    </div>
+
+    <div class="row">
+      <ButtonGroup style="flex: 1;" passive>
+        <Button first style="flex: 1;" v-tooltip="'减小段落缩进'" @click="emitRichTextCommand('indent', '-1')"><IconIndentLeft /></Button>
+        <Popover trigger="click" v-model:value="indentLeftPanelVisible">
+          <template #content>
+            <PopoverMenuItem center @click="emitRichTextCommand('textIndent', '-1')">减小首行缩进</PopoverMenuItem>
+          </template>
+          <Button last class="popover-btn"><IconDown /></Button>
+        </Popover>
+      </ButtonGroup>
+      <div style="width: 10px;"></div>
+      <ButtonGroup style="flex: 1;" passive>
+        <Button first style="flex: 1;" v-tooltip="'增大段落缩进'" @click="emitRichTextCommand('indent', '+1')"><IconIndentRight /></Button>
+        <Popover trigger="click" v-model:value="indentRightPanelVisible">
+          <template #content>
+            <PopoverMenuItem center @click="emitRichTextCommand('textIndent', '+1')">增大首行缩进</PopoverMenuItem>
+          </template>
+          <Button last class="popover-btn"><IconDown /></Button>
+        </Popover>
+      </ButtonGroup>
+    </div> -->
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue'
+import { storeToRefs } from 'pinia'
+import api from '@/services'
+import { useMainStore } from '@/store'
+import emitter, { EmitterEvents } from '@/utils/emitter'
+import { FONTS } from '@/configs/font'
+import useTextFormatPainter from '@/hooks/useTextFormatPainter'
+import message from '@/utils/message'
+import { htmlToText } from '@/utils/common'
+
+import TextColorButton from '@/components/TextColorButton2.vue'
+import CheckboxButton from '@/components/CheckboxButton.vue'
+import ColorPicker from '@/components/ColorPicker/index.vue'
+import Input from '@/components/Input.vue'
+import Button from '@/components/Button.vue'
+import ButtonGroup from '@/components/ButtonGroup.vue'
+import Select from '@/components/Select.vue'
+import SelectGroup from '@/components/SelectGroup.vue'
+import Divider from '@/components/Divider.vue'
+import Popover from '@/components/Popover.vue'
+import RadioButton from '@/components/RadioButton.vue'
+import RadioGroup from '@/components/RadioGroup.vue'
+import PopoverMenuItem from '@/components/PopoverMenuItem.vue'
+
+import { lang } from '@/main'
+
+
+
+const { handleElement, handleElementId, richTextAttrs, textFormatPainter } = storeToRefs(useMainStore())
+
+const { toggleTextFormatPainter } = useTextFormatPainter()
+
+const fontSizeOptions = [
+  '12px', '14px', '16px', '18px', '20px', '22px', '24px', '28px', '32px',
+  '36px', '40px', '44px', '48px', '54px', '60px', '66px', '72px', '76px',
+  '80px', '88px', '96px', '104px', '112px', '120px',
+]
+
+const emitRichTextCommand = (command: string, value?: string) => {
+  emitter.emit(EmitterEvents.RICH_TEXT_COMMAND, { action: { command, value } })
+}
+
+const bulletListPanelVisible = ref(false)
+const orderedListPanelVisible = ref(false)
+const indentLeftPanelVisible = ref(false)
+const indentRightPanelVisible = ref(false)
+
+const bulletListStyleTypeOption = ref(['disc', 'circle', 'square'])
+const orderedListStyleTypeOption = ref(['decimal', 'lower-roman', 'upper-roman', 'lower-alpha', 'upper-alpha', 'lower-greek'])
+
+const link = ref('')
+const linkPopoverVisible = ref(false)
+const AIPopoverVisible = ref(false)
+const isAIWriting = ref(false)
+
+watch(richTextAttrs, () => linkPopoverVisible.value = false)
+watch(handleElementId, () => {
+  if (isAIWriting.value) isAIWriting.value = false
+})
+
+const openLinkPopover = () => {
+  link.value = richTextAttrs.value.link
+}
+const updateLink = (link?: string) => {
+  const linkRegExp = /^(https?):\/\/[\w\-]+(\.[\w\-]+)+([\w\-.,@?^=%&:\/~+#]*[\w\-@?^=%&\/~+#])?$/
+  if (!link || !linkRegExp.test(link)) return message.error('不是正确的网页链接地址')
+
+  emitRichTextCommand('link', link)
+  linkPopoverVisible.value = false
+}
+
+const removeLink = () => {
+  emitRichTextCommand('link')
+  linkPopoverVisible.value = false
+}
+
+const execAI = async (command: string) => {
+  AIPopoverVisible.value = false
+
+  if (!handleElement.value) return
+
+  let content = ''
+  if (handleElement.value.type === 'text' && handleElement.value.content) {
+    content = handleElement.value.content
+  }
+  if (handleElement.value.type === 'shape' && handleElement.value.text && handleElement.value.text.content) {
+    content = handleElement.value.text.content
+  }
+
+  if (!content) return message.error('没有可以执行的文本内容')
+
+  let resultText = ''
+
+  const stream = await api.AI_Writing({
+    content: htmlToText(content),
+    command,
+  })
+
+  isAIWriting.value = true
+
+  const reader: ReadableStreamDefaultReader = stream.body.getReader()
+  const decoder = new TextDecoder('utf-8')
+  
+  const readStream = () => {
+    reader.read().then(({ done, value }) => {
+      if (!isAIWriting.value) return
+      if (done) {
+        isAIWriting.value = false
+        return
+      }
+
+      const chunk = decoder.decode(value, { stream: true })
+      resultText += chunk
+      emitRichTextCommand('replace', resultText)
+
+      readStream()
+    })
+  }
+  readStream()
+}
+</script>
+
+<style lang="scss" scoped>
+.rich-text-base {
+  user-select: none;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+
+  ::v-deep(.ai-loading) {
+    width: 16px;
+    height: 16px;
+    display: inline-block;
+    margin-top: 8px;
+    border: 1px solid $themeColor;
+    border-top-color: transparent;
+    border-radius: 50%;
+    animation: spinner .8s linear infinite;
+  }
+}
+.row {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  // margin-bottom: 10px;
+}
+.font-size-btn {
+  padding: 0 15px;
+}
+.link-popover {
+  width: 240px;
+
+  .btns {
+    margin-top: 10px;
+    text-align: right;
+  }
+}
+.list-wrap {
+  width: 176px;
+  color: #666;
+  padding: 8px;
+  margin: -12px;
+  display: flex;
+  flex-wrap: wrap;
+  align-content: flex-start;
+}
+.list {
+  background-color: $lightGray;
+  padding: 4px 4px 4px 20px;
+  cursor: pointer;
+
+  &:not(:nth-child(3n)) {
+    margin-right: 8px;
+  }
+
+  &:nth-child(4),
+  &:nth-child(5),
+  &:nth-child(6) {
+    margin-top: 8px;
+  }
+
+  &:hover {
+    color: $themeColor;
+
+    span {
+      background-color: $themeColor;
+    }
+  }
+}
+.list-item {
+  width: 24px;
+  height: 12px;
+  position: relative;
+  font-size: 12px;
+  top: -3px;
+
+  span {
+    width: 100%;
+    height: 2px;
+    display: inline-block;
+    position: absolute;
+    top: 8px;
+    background-color: #666;
+  }
+}
+.popover-btn {
+  padding: 0 3px;
+}
+
+@keyframes spinner {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 1 - 1
src/views/Editor/index3.vue

@@ -222,7 +222,7 @@ import Modal from '@/components/Modal.vue'
 import CollapsibleToolbar from '@/components/CollapsibleToolbar/index2.vue'
 import CreateCourseDialog from '@/components/CreateCourseDialog.vue'
 import api from '@/services/course'
-import lang from '../lang/cn.json'
+import { lang } from '@/main'
 
 
 interface ParentWindowWithToolList extends Window {

+ 1 - 1
src/views/components/element/ProsemirrorEditor.vue

@@ -131,7 +131,7 @@ const execCommand = ({ target, action }: RichTextCommand) => {
       addMark(editorView, mark)
 
       if (item.value && !document.fonts.check(`16px ${item.value}`)) {
-        message.warning(lang.ssFontLoadWait)
+        // message.warning(lang.ssFontLoadWait)
       }
     }
     else if (item.command === 'fontsize' && item.value) {

+ 15 - 1
src/views/lang/cn.json

@@ -859,5 +859,19 @@
   "ssSpkJustNow": "刚刚生成",
   "ssSpkSecondsAgoTpl": "{n} 秒前生成",
   "ssSpkMinutesAgoTpl": "{n} 分钟前生成",
-  "ssResult": "结果"
+  "ssResult": "结果",
+  "ssConfirmDel": "确认删除",
+  "ssConfirmDelContent": "此操作不可恢复,是否继续?",
+  "searchFont": "搜索字体",
+  "searchFontSize": "搜索字体大小",
+  "textColor": "文字颜色",
+  "highlight": "文字高亮",
+  "fontSizeAdd": "增加字号",
+  "fontSizeReduce": "减少字号",
+  "bold": "加粗",
+  "italic": "斜体",
+  "underline": "下划线",
+  "strikethrough": "删除线",
+  "flipVertically": "垂直翻转",
+  "flipHorizontally": "水平翻转"
 }

+ 15 - 1
src/views/lang/en.json

@@ -859,5 +859,19 @@
   "ssSpkJustNow": "Just now",
   "ssSpkSecondsAgoTpl": "{n}s ago",
   "ssSpkMinutesAgoTpl": "{n}m ago",
-  "ssResult": "Result"
+  "ssResult": "Result",
+  "ssConfirmDel": "Confirm Delete",
+  "ssConfirmDelContent": "This operation cannot be recovered, continue?",
+  "searchFont": "Search Font",
+  "searchFontSize": "Search Font Size",
+  "textColor": "Text Color",
+  "highlight": "Text Highlight Color",
+  "fontSizeAdd": "Increase Font Size",
+  "fontSizeReduce": "Reduce Font Size",
+  "bold": "Bold",
+  "italic": "Italic",
+  "underline": "Underline",
+  "strikethrough": "Strikethrough",
+  "flipVertically": "Flip Vertically",
+  "flipHorizontally": "Flip Horizontally"
 }

+ 15 - 1
src/views/lang/hk.json

@@ -859,5 +859,19 @@
   "ssSpkJustNow": "剛剛生成",
   "ssSpkSecondsAgoTpl": "{n} 秒前生成",
   "ssSpkMinutesAgoTpl": "{n} 分鐘前生成",
-  "ssResult": "結果"
+  "ssResult": "結果",
+  "ssConfirmDel": "確認刪除",
+  "ssConfirmDelContent": "此操作不可恢復,是否繼續?",
+  "searchFont": "搜索字型",
+  "searchFontSize": "搜索字型大小",
+  "textColor": "文字顏色",
+  "highlight": "文字高亮",
+  "fontSizeAdd": "增加字号",
+  "fontSizeReduce": "減少字号",
+  "bold": "加粗",
+  "italic": "斜體",
+  "underline": "下劃線",
+  "strikethrough": "刪除線",
+  "flipVertically": "垂直翻轉",
+  "flipHorizontally": "水平翻轉"
 }