Explorar o código

添加预览功能

cc12458 hai 6 meses
pai
achega
c735a5b374

+ 1 - 0
index.html

@@ -5,6 +5,7 @@
   <link rel="icon" href="/favicon.ico">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>中医健康管理平台</title>
+  <link rel="stylesheet" href="/editor/preview.css">
 </head>
 <body>
 <div id="app"></div>

+ 65 - 0
public/editor/loader.css

@@ -0,0 +1,65 @@
+.loader-container {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  display: flex;
+  flex-direction: row;
+  transform: translate(-50%, -50%);
+}
+
+.loader-container div {
+  height: 20px;
+  width: 5px;
+  background: #FE4A49;
+  margin: 3px;
+  border-radius: 25px;
+}
+
+.loader-container div:nth-child(1) {
+  animation: loader 1s ease-in-out infinite 0s;
+}
+
+.loader-container div:nth-child(2) {
+  animation: loader 1s ease-in-out infinite 0.1s;
+}
+
+.loader-container div:nth-child(3) {
+  animation: loader 1s ease-in-out infinite 0.2s;
+}
+
+.loader-container div:nth-child(4) {
+  animation: loader 1s ease-in-out infinite 0.3s;
+}
+
+.loader-container div:nth-child(5) {
+  animation: loader 1s ease-in-out infinite 0.4s;
+}
+
+.loader-container div:nth-child(6) {
+  animation: loader 1s ease-in-out infinite 0.5s;
+}
+
+.loader-container div:nth-child(7) {
+  animation: loader 1s ease-in-out infinite 0.6s;
+}
+
+@keyframes loader {
+  0% {
+    transform: scaleY(1);
+    background: #FED766;
+  }
+  25% {
+    background: #009FB7;
+  }
+  50% {
+    transform: scaleY(2);
+    background: #59CD90;
+  }
+  75% {
+    background: #FE4A49;
+  }
+  100% {
+    transform: scaleY(1);
+    background: #D91E36;
+  }
+}

+ 258 - 0
public/editor/preview.css

@@ -0,0 +1,258 @@
+:root,
+:host {
+  --w-e-textarea-bg-color: #fff;
+  --w-e-textarea-color: #000;
+  --w-e-textarea-font-size: 16px;
+  --w-e-textarea-border-color: #ccc;
+  --w-e-textarea-slight-border-color: #e8e8e8;
+  --w-e-textarea-slight-color: #d4d4d4;
+  --w-e-textarea-slight-bg-color: #f5f2f0;
+  --w-e-textarea-selected-border-color: #B4D5FF;
+  --w-e-textarea-handler-bg-color: #4290f7;
+}
+
+.editor-generate-preview-wrapper {
+  padding-left: 1em;
+  padding-right: 1em;
+  font-size: var(--w-e-textarea-font-size);
+}
+
+.w-e-text-container {
+  position: relative;
+  margin: 0;
+  padding: 0;
+  height: 100%;
+  outline: none;
+  background-color: var(--w-e-textarea-bg-color);
+  color: var(--w-e-textarea-color);
+  box-sizing: border-box;
+}
+.w-e-text-container > *:first-child {
+  margin-top: 0;
+}
+
+.w-e-text-container li, .w-e-text-container p, .w-e-text-container td, .w-e-text-container th {
+  line-height: 1.5
+}
+
+.w-e-text-container h1 {
+  font-size: 2.25em;
+  margin: 0.67em 0;
+  font-weight: 700;
+}
+
+.w-e-text-container h2 {
+  font-size: 2em;
+  margin: 0.65em 0;
+  font-weight: 700;
+}
+
+.w-e-text-container h3 {
+  font-size: 1.75em;
+  margin: 0.65em 0;
+  font-weight: 600;
+}
+
+.w-e-text-container h4 {
+  font-size: 1.5em;
+  margin: 0.6em 0;
+  font-weight: 600;
+}
+
+.w-e-text-container h5 {
+  font-size: 1.25em;
+  margin: 0.5em 0;
+  font-weight: 600;
+}
+
+.w-e-text-container p {
+  font-size: 1em;
+  margin: 0.25em 0
+}
+
+.w-e-text-container span {
+  text-indent: 0
+}
+
+.w-e-text-container blockquote {
+  line-height: 1.5;
+  background-color: var(--w-e-textarea-slight-bg-color);
+  border-left: 8px solid var(--w-e-textarea-selected-border-color);
+  display: block;
+  font-size: 100%;
+  margin: 10px 0;
+  padding: 10px;
+}
+
+.w-e-text-container ul,
+.w-e-text-container ol {
+  padding-left: 2em;
+}
+
+.w-e-text-container [data-w-e-type="todo"] {
+  position: relative;
+  padding-left: 2em;
+
+}
+
+.w-e-text-container [data-w-e-type="todo"] input[type='checkbox'] {
+  position: absolute;
+  top: 0.35em;
+  left: 0.75em;
+}
+
+.w-e-text-container [data-w-e-type="todo"] input[type='checkbox']:disabled {
+  color: red;
+}
+
+.w-e-text-container hr {
+  background-color: var(--w-e-textarea-border-color);
+  display: block;
+  margin: 1em auto;
+  height: 1px;
+  border: 0;
+}
+
+
+.w-e-text-container img {
+  display: inline !important;
+  max-width: 100%;
+  min-width: 20px;
+  min-height: 20px;
+  object-fit: scale-down;
+  cursor: default;
+}
+
+.w-e-text-container video {
+  margin: auto;
+  max-width: 100%;
+}
+
+.w-e-text-container table {
+  margin: 1em 0;
+  border-collapse: collapse
+}
+
+.w-e-text-container table td,
+.w-e-text-container table th {
+  border: 1px solid var(--w-e-textarea-border-color);
+  line-height: 1.5;
+  min-width: 30px;
+  padding: 3px 5px;
+  text-align: left
+}
+
+.w-e-text-container table th {
+  background-color: var(--w-e-textarea-slight-bg-color);
+  font-weight: 700;
+  text-align: center
+}
+
+.w-e-text-container pre {
+
+}
+
+.w-e-text-container pre > code {
+  display: block;
+  margin: .5em 0;
+  padding: 1em;
+  background-color: var(--w-e-textarea-slight-bg-color);
+  border: 1px solid var(--w-e-textarea-slight-border-color);
+  border-radius: 4px 4px;
+  font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
+  font-size: 14px;
+  line-height: 1.5;
+  text-indent: 0;
+  text-align: left;
+  text-shadow: 0 1px #fff;
+  word-wrap: normal;
+  -webkit-hyphens: none;
+  hyphens: none;
+  overflow: auto;
+  -moz-tab-size: 4;
+  -o-tab-size: 4;
+  tab-size: 4;
+  white-space: pre;
+  word-break: normal;
+  word-spacing: normal;
+}
+
+.w-e-text-container pre > code .token.cdata,
+.w-e-text-container pre > code .token.comment,
+.w-e-text-container pre > code .token.doctype,
+.w-e-text-container pre > code .token.prolog {
+  color: #708090
+}
+
+.w-e-text-container pre > code .token.punctuation {
+  color: #999
+}
+
+.w-e-text-container pre > code .token.namespace {
+  opacity: .7
+}
+
+.w-e-text-container pre > code .token.boolean,
+.w-e-text-container pre > code .token.constant,
+.w-e-text-container pre > code .token.deleted,
+.w-e-text-container pre > code .token.number,
+.w-e-text-container pre > code .token.property,
+.w-e-text-container pre > code .token.symbol,
+.w-e-text-container pre > code .token.tag {
+  color: #905
+}
+
+.w-e-text-container pre > code .token.attr-name,
+.w-e-text-container pre > code .token.builtin,
+.w-e-text-container pre > code .token.char,
+.w-e-text-container pre > code .token.inserted,
+.w-e-text-container pre > code .token.selector,
+.w-e-text-container pre > code .token.string {
+  color: #690
+}
+
+.w-e-text-container pre > code .language-css .token.string,
+.w-e-text-container pre > code .style .token.string,
+.w-e-text-container pre > code .token.entity,
+.w-e-text-container pre > code .token.operator,
+.w-e-text-container pre > code .token.url {
+  color: #9a6e3a
+}
+
+.w-e-text-container pre > code .token.atrule,
+.w-e-text-container pre > code .token.attr-value,
+.w-e-text-container pre > code .token.keyword {
+  color: #07a
+}
+
+.w-e-text-container pre > code .token.class-name,
+.w-e-text-container pre > code .token.function {
+  color: #dd4a68
+}
+
+.w-e-text-container pre > code .token.important,
+.w-e-text-container pre > code .token.regex,
+.w-e-text-container pre > code .token.variable {
+  color: #e90
+}
+
+.w-e-text-container pre > code .token.bold,
+.w-e-text-container pre > code .token.important {
+  font-weight: 700
+}
+
+.w-e-text-container pre > code .token.italic {
+  font-style: italic
+}
+
+.w-e-text-container pre > code .token.entity {
+  cursor: help
+}
+
+
+.w-e-text-container code {
+  background-color: var(--w-e-textarea-slight-bg-color);
+  border-radius: 3px;
+  font-family: monospace;
+  padding: 3px
+}

+ 57 - 0
public/editor/preview.html

@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>加载中...</title>
+  <link href="preview.css" rel="stylesheet">
+  <link href="loader.css" rel="stylesheet">
+  <style>
+    html, body {
+      margin: 0;
+      padding: 0;
+    }
+  </style>
+</head>
+<body>
+<div class="loader-container">
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+</div>
+<div class="editor-generate-preview-wrapper">
+  <div class="header"></div>
+  <div class="content w-e-text-container"></div>
+  <div class="footer"></div>
+</div>
+<script>
+  const host = ``;
+
+  /* 渲染 header 和 footer  */
+  function render() {
+
+  }
+
+  async function load() {
+    try {
+      if (!host) throw { message: `请求地址为空` };
+      const request = await fetch(`${host}${location.search}&${location.hash.split('?')[1] || ''}`);
+      const result = await request.json();
+      document.title = result.title || `加载完成`;
+      document.querySelector('.w-e-text-container').innerHTML = result.content || `暂无内容`;
+      render(result);
+    } catch (e) {
+      document.querySelector('.w-e-text-container').innerHTML = e.message || `请求未成功`
+    } finally {
+      document.querySelector('.loader-container').style.display = 'none';
+    }
+  }
+
+  document.addEventListener('DOMContentLoaded', load);
+</script>
+</body>
+</html>

BIN=BIN
src/pages/editor/assets/iphone15-pro-max.png


+ 1 - 0
src/pages/editor/config.ts

@@ -105,5 +105,6 @@ export function getDefaultToolbarConfig() {
     'redo',
     '|',
     'fullScreen',
+    'preview',
   ];
 }

+ 1 - 0
src/pages/editor/icon/preview.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M977.824 454.016C826.432 280.288 672.512 192 516.128 192c-156.384 0-310.304 88.288-461.696 262.016a112 112 0 0 0-1.632 145.248c138.24 165.984 293.088 250.24 463.328 250.24s325.088-84.256 463.328-250.24a112 112 0 0 0-1.632-145.248z m-47.552 104.32c-126.848 152.32-264.48 227.2-414.144 227.2s-287.296-74.88-414.144-227.2a48 48 0 0 1 0.704-62.24C243.008 335.072 380.8 256 516.128 256s273.152 79.072 413.44 240.064c15.488 17.792 15.776 44.16 0.704 62.272z" p-id="4755"></path><path d="M512 288a224 224 0 1 0 0 448 224 224 0 0 0 0-448z m-64 224a64 64 0 1 1 0.032-128.032A64 64 0 0 1 448 512z" p-id="4756"></path></svg>

+ 2 - 1
src/pages/editor/index.ts

@@ -1,5 +1,6 @@
 import __index_vue from './index.vue';
+import __preview_vue from './preview.vue';
 
 export type * from './types';
 export { isEditorInstance } from './tools';
-export { __index_vue as Editor };
+export { __index_vue as Editor, __preview_vue as EditorPreview };

+ 19 - 9
src/pages/editor/index.vue

@@ -1,11 +1,11 @@
 <script setup lang="ts">
-import '@wangeditor/editor/dist/css/style.css';
-
+import './style/index.scss';
 import { message, notification } from 'ant-design-vue';
 import type { NotificationArgsProps } from 'ant-design-vue/es/notification';
 import type { MessageArgsProps } from 'ant-design-vue/es/message';
 import type { EditorEmits, EditorInstance, EditorProps } from './types';
 import { useEditor } from './useEditor';
+import Preview from './preview.vue';
 
 defineOptions({
   name: 'EditorContainer',
@@ -14,8 +14,8 @@ defineOptions({
 const props = defineProps<EditorProps>();
 const emits = defineEmits<EditorEmits>();
 
-let lastText = '';
-let lastContent = '';
+const lastText = ref('');
+const lastContent = ref('');
 const [model, modifiers] = defineModel<string, 'html' | 'text'>('content', {
   set(value) {
     return modifiers.text ? lastText : value;
@@ -23,6 +23,7 @@ const [model, modifiers] = defineModel<string, 'html' | 'text'>('content', {
 });
 
 const container = useTemplateRef('container');
+const preview = useTemplateRef('preview');
 
 const instance = useEditor(container, props, {
   onCreated(editor) {
@@ -31,8 +32,8 @@ const instance = useEditor(container, props, {
   },
   onChange(editor, content) {
     emits('change', editor, content);
-    lastText = content.text;
-    lastContent = content.html;
+    lastText.value = content.text;
+    lastContent.value = content.html;
     model.value = content.html;
   },
 });
@@ -40,7 +41,7 @@ const instance = useEditor(container, props, {
 watchEffect(() => {
   if (modifiers.text) return;
   const value = model.value;
-  if (instance.editor.value && value != null && value !== lastContent) {
+  if (instance.editor.value && value != null && value !== unref(lastContent)) {
     instance.editor.value.setHtml(value ?? '');
   }
 });
@@ -56,13 +57,19 @@ function monitor(editor: EditorInstance) {
   editor.on('upload:success', (event) => n('success', event));
   editor.on('upload:failed', (event) => n('error', event));
   editor.on('message', (event) => m(event));
+  if (props.previewable) editor.on('preview', () => { preview.value?.toggle() });
 }
 
 defineExpose(instance);
 </script>
 
 <template>
-  <div class="editor-container" ref="container"></div>
+  <div class="editor-container editor-generate-preview-wrapper" ref="container">
+    <preview ref="preview" v-if="previewable" :content="lastContent">
+      <template #preview-header><slot name="preview-header"></slot></template>
+      <template #preview-footer><slot name="preview-footer"></slot></template>
+    </preview>
+  </div>
 </template>
 
 <style scoped lang="scss">
@@ -70,15 +77,18 @@ defineExpose(instance);
   display: flex;
   flex-direction: column-reverse !important;
   height: 100%;
+  &.editor-generate-preview-wrapper {
+    padding: 0 0 1em;
+  }
   &.w-e-full-screen-container {
     background-color: #fff;
   }
   &:not(.w-e-full-screen-container) {
     max-height: var(--height, 100%);
+    position: relative;
   }
   overflow: hidden;
 
-  position: relative;
   :deep(.ant-notification) {
     top: 40px !important;
     position: absolute !important;

+ 2 - 1
src/pages/editor/menus/index.ts

@@ -1 +1,2 @@
-export { default as deleteVideoMenu } from './delete-video';
+export { default as deleteVideoMenu } from './delete-video';
+export { default as previewMenu } from './preview';

+ 34 - 0
src/pages/editor/menus/preview.ts

@@ -0,0 +1,34 @@
+import type { IButtonMenu, IDomEditor } from '@wangeditor/core';
+import previewRaw from '../icon/preview.svg?raw';
+
+class PreviewButtonMenu implements IButtonMenu {
+  readonly title = '预览';
+  readonly iconSvg = previewRaw;
+  readonly tag = 'button';
+
+  getValue(editor: IDomEditor): string | boolean {
+    // 无需获取 val
+    return '';
+  }
+
+  isActive(editor: IDomEditor): boolean {
+    // 无需 active
+    return false;
+  }
+
+  isDisabled(editor: IDomEditor): boolean {
+    return editor.getText().trim().length === 0;
+  }
+
+  exec(editor: IDomEditor, value: string | boolean) {
+    if (this.isDisabled(editor)) return;
+    editor.emit('preview', { mode: 'editor' });
+  }
+}
+
+export default {
+  key: 'preview',
+  factory(): PreviewButtonMenu {
+    return new PreviewButtonMenu();
+  },
+};

+ 140 - 0
src/pages/editor/preview.vue

@@ -0,0 +1,140 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+import type { VxeModalInstance } from 'vxe-pc-ui';
+
+type OptionalNumber = number | void;
+
+interface Props {
+  title?: string;
+  content?: string;
+  offset?: number;
+}
+
+const { title = '预览', offset = 60 } = defineProps<Props>();
+
+const showPopup = ref(false);
+
+const openEvent = () => {
+  showPopup.value = true;
+  if (modal.value?.isMinimized()) modal.value?.revert();
+};
+const modal = useTemplateRef<VxeModalInstance>('modal');
+const modalProps = reactive({
+  className: `editor-preview-wrapper`,
+  lockView: false,
+  lockScroll: false,
+  mask: false,
+  escClosable: true,
+  showMinimize: true,
+  zoomConfig: {
+    minimizeOffsetMethod(params) {
+      console.log(params, 'log:minimizeOffsetMethod');
+      return {
+        top: 0,
+      };
+    },
+  },
+  width: void 0 as OptionalNumber,
+  height: void 0 as OptionalNumber,
+  position: { top: void 0 as OptionalNumber, left: void 0 as OptionalNumber },
+});
+
+const device = shallowRef({ width: 430, height: 932, offsetX: 40, offsetY: 40 });
+const deviceStyle = computed(() => {
+  return {
+    '--width': device.value.width,
+    '--height': device.value.height,
+    '--offsetX': device.value.offsetX,
+    '--offsetY': device.value.offsetY,
+    '--offsetTop': 60,
+    '--offsetBottom': 40,
+  };
+});
+watchEffect(() => {
+  const { width, height, offsetX, offsetY } = device.value;
+  const _width = width + offsetX * 2;
+  const _height = height + offsetY * 2;
+  const isFull = _height + 40 > window.innerHeight;
+  modalProps.width = _width + /* 边框 1px * 2 */ 2;
+  modalProps.height = isFull ? window.innerHeight - offset : void 0;
+  modalProps.position.top = isFull ? offset : Math.max(0, Math.min(offset, window.innerHeight - (_height + 47.5)));
+  modalProps.position.left = window.innerWidth - _width;
+  modalProps.className = `editor-preview-wrapper ${isFull ? 'is-full' : ''}`;
+});
+const toggle = (open?: boolean) => {
+  if (open) showPopup.value = open;
+  else if (modal.value?.isMinimized()) modal.value?.revert();
+  else showPopup.value = !showPopup.value;
+};
+
+defineExpose({ toggle });
+</script>
+
+<template>
+  <vxe-modal ref="modal" v-model="showPopup" :title="title" v-bind="modalProps as any">
+    <template #title="{ minimized }">
+      <div v-if="minimized">预览</div>
+      <div v-else>{{ title }}</div>
+    </template>
+    <template #default>
+      <div :style="deviceStyle">
+        <img class="mock-wrapper" src="./assets/iphone15-pro-max.png" alt="" />
+        <div class="content-wrapper editor-generate-preview-wrapper">
+          <slot name="preview-header" class="header"></slot>
+          <slot name="preview-content" class="content w-e-text-container" :content>
+            <div class="content w-e-text-container" v-html="content"></div>
+          </slot>
+          <slot name="preview-footer" class="footer"></slot>
+        </div>
+      </div>
+    </template>
+  </vxe-modal>
+  <slot :toggle="toggle"></slot>
+</template>
+<style lang="scss">
+.editor-preview-wrapper {
+  &:not(.is-full) {
+    .vxe-modal--box {
+      height: auto !important;
+    }
+  }
+  .vxe-modal--body-default {
+    padding: 0 !important;
+  }
+  .vxe-modal--content {
+    overflow-x: hidden !important;
+    overflow-y: auto !important;
+    > div {
+      position: relative;
+      width: calc(var(--width) * 1px + var(--offsetX) * 2px);
+      height: calc(var(--height) * 1px + var(--offsetY) * 2px);
+    }
+  }
+}
+</style>
+<style scoped lang="scss">
+.mock-wrapper {
+  position: absolute;
+  z-index: 2;
+  width: 100%;
+  height: 100%;
+  object-fit: scale-down;
+  pointer-events: none;
+}
+.content-wrapper {
+  position: absolute;
+  margin: 40px;
+  top: -0.5px;
+  left: -0.5px;
+  padding-top: calc(var(--offsetTop) * 1px);
+  padding-bottom: calc(var(--offsetBottom) * 1px);
+  max-width: calc(var(--width) * 1px);
+  max-height: calc(var(--height) * 1px);
+  border-radius: 40px;
+  overflow: auto;
+  &::-webkit-scrollbar {
+    display: none; /* 隐藏滚动条 */
+  }
+  //background-color: rgba(255, 0, 0, 0.67);
+}
+</style>

+ 89 - 0
src/pages/editor/style/editor-content.scss

@@ -0,0 +1,89 @@
+.w-e-text-container {
+  [data-slate-editor] {
+    > *:first-child {
+      margin-top: 0;
+    }
+
+    .w-e-image-container {
+      margin: 0 3px;
+      display: inline-block;
+
+      &:hover {
+        box-shadow: 0 0 0 2px var(--w-e-textarea-selected-border-color)
+      }
+    }
+
+    .w-e-selected-image-container {
+      position: relative;
+      overflow: hidden;
+
+      &:hover {
+        box-shadow: none;
+      }
+
+      .w-e-image-dragger {
+        position: absolute;
+        width: 7px;
+        height: 7px;
+        background-color: var(--w-e-textarea-handler-bg-color);
+      }
+
+      .left-top {
+        top: 0;
+        left: 0;
+        cursor: nwse-resize;
+      }
+
+      .right-top {
+        top: 0;
+        right: 0;
+        cursor: nesw-resize;
+      }
+
+      .left-bottom {
+        bottom: 0;
+        left: 0;
+        cursor: nesw-resize;
+      }
+
+      .right-bottom {
+        right: 0;
+        bottom: 0;
+        cursor: nwse-resize;
+      }
+    }
+  }
+
+  [contenteditable=false] {
+    .w-e-image-container {
+      &:hover {
+        box-shadow: none
+      }
+    }
+  }
+
+  .table-container {
+    border: 1px dashed var(--w-e-textarea-border-color);
+    border-radius: 5px;
+    margin-top: 10px;
+    overflow-x: auto;
+    padding: 10px;
+    width: 100%
+  }
+}
+
+.w-e-textarea-divider {
+  border-radius: 3px;
+  padding: 0.5em;
+}
+
+.w-e-textarea-video-container {
+  background-image: linear-gradient(45deg, #eee 25%, transparent 0, transparent 75%, #eee 0, #eee), linear-gradient(45deg, #eee 25%, #fff 0, #fff 75%, #eee 0, #eee);
+  background-position: 0 0, 10px 10px;
+  background-size: 20px 20px;
+  border: 1px dashed var(--w-e-textarea-border-color);
+  border-radius: 5px;
+  margin: 10px auto 0;
+  padding: 10px 0;
+  text-align: center
+}

+ 403 - 0
src/pages/editor/style/editor-toolbar.scss

@@ -0,0 +1,403 @@
+.w-e-toolbar * {
+  margin: 0;
+  padding: 0;
+  outline: none;
+  line-height: 1.5;
+  box-sizing: border-box;
+}
+
+.w-e-bar {
+  background-color: var(--w-e-toolbar-bg-color);
+  color: var(--w-e-toolbar-color);
+  font-size: 14px;
+  padding: 0 5px
+}
+
+.w-e-bar svg {
+  fill: var(--w-e-toolbar-color);
+  height: 14px;
+  width: 14px
+}
+
+.w-e-bar-show {
+  display: flex
+}
+
+.w-e-bar-hidden {
+  display: none
+}
+
+.w-e-hover-bar {
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 5px #0000001f;
+  position: absolute
+}
+
+.w-e-toolbar {
+  flex-wrap: wrap;
+  position: relative
+}
+
+.w-e-bar-divider {
+  background-color: var(--w-e-toolbar-border-color);
+  display: inline-flex;
+  height: 40px;
+  margin: 0 5px;
+  width: 1px
+}
+
+.w-e-bar-item {
+  display: flex;
+  height: 40px;
+  padding: 4px;
+  position: relative;
+  text-align: center
+}
+
+.w-e-bar-item, .w-e-bar-item button {
+  align-items: center;
+  justify-content: center
+}
+
+.w-e-bar-item button {
+  background: transparent;
+  border: none;
+  color: var(--w-e-toolbar-color);
+  cursor: pointer;
+  display: inline-flex;
+  height: 32px;
+  overflow: hidden;
+  padding: 0 8px;
+  white-space: nowrap
+}
+
+.w-e-bar-item button:hover {
+  background-color: var(--w-e-toolbar-active-bg-color);
+  color: var(--w-e-toolbar-active-color)
+}
+
+.w-e-bar-item button .title {
+  margin-left: 5px
+}
+
+.w-e-bar-item .active {
+  background-color: var(--w-e-toolbar-active-bg-color);
+  color: var(--w-e-toolbar-active-color)
+}
+
+.w-e-bar-item .disabled {
+  color: var(--w-e-toolbar-disabled-color);
+  cursor: not-allowed
+}
+
+.w-e-bar-item .disabled svg {
+  fill: var(--w-e-toolbar-disabled-color)
+}
+
+.w-e-bar-item .disabled:hover {
+  background-color: var(--w-e-toolbar-bg-color);
+  color: var(--w-e-toolbar-disabled-color)
+}
+
+.w-e-bar-item .disabled:hover svg {
+  fill: var(--w-e-toolbar-disabled-color)
+}
+
+.w-e-menu-tooltip-v5:before {
+  background-color: var(--w-e-toolbar-active-color);
+  border-radius: 5px;
+  color: var(--w-e-toolbar-bg-color);
+  content: attr(data-tooltip);
+  font-size: .75em;
+  opacity: 0;
+  padding: 5px 10px;
+  position: absolute;
+  text-align: center;
+  top: 40px;
+  transition: opacity .6s;
+  visibility: hidden;
+  white-space: pre;
+  z-index: 1
+}
+
+.w-e-menu-tooltip-v5:after {
+  border: 5px solid transparent;
+  border-bottom: 5px solid var(--w-e-toolbar-active-color);
+  content: "";
+  opacity: 0;
+  position: absolute;
+  top: 30px;
+  transition: opacity .6s;
+  visibility: hidden
+}
+
+.w-e-menu-tooltip-v5:hover:after, .w-e-menu-tooltip-v5:hover:before {
+  opacity: 1;
+  visibility: visible
+}
+
+.w-e-menu-tooltip-v5.tooltip-right:before {
+  left: 100%;
+  top: 10px
+}
+
+.w-e-menu-tooltip-v5.tooltip-right:after {
+  border-bottom-color: transparent;
+  border-left-color: transparent;
+  border-right-color: var(--w-e-toolbar-active-color);
+  border-top-color: transparent;
+  left: 100%;
+  margin-left: -10px;
+  top: 16px
+}
+
+.w-e-bar-item-group .w-e-bar-item-menus-container {
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 10px #0000001f;
+  display: none;
+  left: 0;
+  margin-top: 40px;
+  position: absolute;
+  top: 0;
+  z-index: 1
+}
+
+.w-e-bar-item-group:hover .w-e-bar-item-menus-container {
+  display: block
+}
+
+.w-e-select-list {
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 10px #0000001f;
+  left: 0;
+  margin-top: 40px;
+  max-height: 350px;
+  min-width: 100px;
+  overflow-y: auto;
+  position: absolute;
+  top: 0;
+  z-index: 1
+}
+
+.w-e-select-list ul {
+  line-height: 1;
+  list-style: none
+}
+
+.w-e-select-list ul .selected {
+  background-color: var(--w-e-toolbar-active-bg-color)
+}
+
+.w-e-select-list ul li {
+  cursor: pointer;
+  padding: 7px 0 7px 25px;
+  position: relative;
+  text-align: left;
+  white-space: nowrap
+}
+
+.w-e-select-list ul li:hover {
+  background-color: var(--w-e-toolbar-active-bg-color)
+}
+
+.w-e-select-list ul li svg {
+  left: 0;
+  margin-left: 5px;
+  margin-top: -7px;
+  position: absolute;
+  top: 50%
+}
+
+.w-e-bar-bottom .w-e-select-list {
+  bottom: 0;
+  margin-bottom: 40px;
+  margin-top: 0;
+  top: inherit
+}
+
+.w-e-drop-panel {
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 10px #0000001f;
+  margin-top: 40px;
+  min-width: 200px;
+  padding: 10px;
+  position: absolute;
+  top: 0;
+  z-index: 1
+}
+
+.w-e-bar-bottom .w-e-drop-panel {
+  bottom: 0;
+  margin-bottom: 40px;
+  margin-top: 0;
+  top: inherit
+}
+
+.w-e-modal {
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 10px #0000001f;
+  color: var(--w-e-toolbar-color);
+  font-size: 14px;
+  min-height: 40px;
+  min-width: 100px;
+  padding: 20px 15px 0;
+  position: absolute;
+  text-align: left;
+  z-index: 1
+}
+
+.w-e-modal .btn-close {
+  cursor: pointer;
+  line-height: 1;
+  padding: 5px;
+  position: absolute;
+  right: 8px;
+  top: 7px
+}
+
+.w-e-modal .btn-close svg {
+  fill: var(--w-e-toolbar-color);
+  height: 10px;
+  width: 10px
+}
+
+.w-e-modal .babel-container {
+  display: block;
+  margin-bottom: 15px
+}
+
+.w-e-modal .babel-container span {
+  display: block;
+  margin-bottom: 10px
+}
+
+.w-e-modal .button-container {
+  margin-bottom: 15px
+}
+
+.w-e-modal button {
+  background-color: var(--w-e-modal-button-bg-color);
+  border: 1px solid var(--w-e-modal-button-border-color);
+  border-radius: 4px;
+  color: var(--w-e-toolbar-color);
+  cursor: pointer;
+  font-weight: 400;
+  height: 32px;
+  padding: 4.5px 15px;
+  text-align: center;
+  touch-action: manipulation;
+  transition: all .3s cubic-bezier(.645, .045, .355, 1);
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  user-select: none;
+  white-space: nowrap
+}
+
+.w-e-modal input[type=number], .w-e-modal input[type=text], .w-e-modal textarea {
+  font-feature-settings: "tnum";
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-modal-button-border-color);
+  border-radius: 4px;
+  color: var(--w-e-toolbar-color);
+  font-variant: tabular-nums;
+  padding: 4.5px 11px;
+  transition: all .3s;
+  width: 100%
+}
+
+.w-e-modal textarea {
+  min-height: 60px
+}
+
+body .w-e-modal, body .w-e-modal * {
+  box-sizing: border-box
+}
+
+
+.w-e-panel-content-color {
+  list-style: none;
+  text-align: left;
+  width: 230px
+}
+
+.w-e-panel-content-color li {
+  border: 1px solid var(--w-e-toolbar-bg-color);
+  border-radius: 3px 3px;
+  cursor: pointer;
+  display: inline-block;
+  padding: 2px
+}
+
+.w-e-panel-content-color li:hover {
+  border-color: var(--w-e-toolbar-color)
+}
+
+.w-e-panel-content-color li .color-block {
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px 3px;
+  height: 17px;
+  width: 17px
+}
+
+.w-e-panel-content-color .active {
+  border-color: var(--w-e-toolbar-color)
+}
+
+.w-e-panel-content-color .clear {
+  line-height: 1.5;
+  margin-bottom: 5px;
+  width: 100%
+}
+
+.w-e-panel-content-color .clear svg {
+  height: 16px;
+  margin-bottom: -4px;
+  width: 16px
+}
+
+.w-e-panel-content-emotion {
+  font-size: 20px;
+  list-style: none;
+  text-align: left;
+  width: 300px
+}
+
+.w-e-panel-content-emotion li {
+  border-radius: 3px 3px;
+  cursor: pointer;
+  display: inline-block;
+  padding: 0 5px
+}
+
+.w-e-panel-content-emotion li:hover {
+  background-color: var(--w-e-textarea-slight-bg-color)
+}
+
+.w-e-panel-content-table {
+  background-color: var(--w-e-toolbar-bg-color)
+}
+
+.w-e-panel-content-table table {
+  border-collapse: collapse
+}
+
+.w-e-panel-content-table td {
+  border: 1px solid var(--w-e-toolbar-border-color);
+  cursor: pointer;
+  height: 15px;
+  padding: 3px 5px;
+  width: 20px
+}
+
+.w-e-panel-content-table td.active {
+  background-color: var(--w-e-toolbar-active-bg-color)
+}

+ 85 - 0
src/pages/editor/style/index.scss

@@ -0,0 +1,85 @@
+//@use "/editor/preview.css";
+@use "editor-toolbar";
+@use "editor-content";
+
+:root,
+:host {
+  --w-e-toolbar-color: #595959;
+  --w-e-toolbar-bg-color: #fff;
+  --w-e-toolbar-active-color: #333;
+  --w-e-toolbar-active-bg-color: #f1f1f1;
+  --w-e-toolbar-disabled-color: #999;
+  --w-e-toolbar-border-color: #e8e8e8;
+  --w-e-modal-button-bg-color: #fafafa;
+  --w-e-modal-button-border-color: #d9d9d9;
+}
+
+
+.w-e-text-container {
+  .w-e-scroll {
+    -webkit-overflow-scrolling: touch;
+    height: 100%
+  }
+
+  [data-slate-editor] {
+    padding: 0 1em;
+    outline: 0;
+    min-height: 100%;
+    word-wrap: break-word;
+    white-space: pre-wrap;
+    border-top: 1px solid transparent;
+
+    [data-selected=true] {
+      box-shadow: 0 0 0 2px var(--w-e-textarea-selected-border-color)
+    }
+  }
+}
+
+
+.w-e-text-placeholder {
+  position: absolute;
+  left: 10px;
+  top: 17px;
+  width: 90%;
+  font-style: italic;
+  color: var(--w-e-textarea-slight-color);
+  pointer-events: none;
+  user-select: none
+}
+
+.w-e-progress-bar {
+  background-color: var(--w-e-textarea-handler-bg-color);
+  height: 1px;
+  position: absolute;
+  transition: width .3s;
+  width: 0
+}
+
+.w-e-full-screen-container {
+  position: fixed;
+  top: 0 !important;
+  right: 0 !important;
+  bottom: 0 !important;
+  left: 0 !important;
+  display: flex !important;
+  flex-direction: column !important;
+  margin: 0 !important;
+  padding: 0 !important;
+  width: 100% !important;
+  height: 100% !important;
+
+  [data-w-e-textarea=true] {
+    flex: 1 !important
+  }
+}
+
+.w-e-max-length-info {
+  bottom: .5em;
+  right: 1em;
+  color: var(--w-e-textarea-slight-color);
+  pointer-events: none;
+  position: absolute;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  user-select: none
+}

+ 2 - 1
src/pages/editor/tools.ts

@@ -1,7 +1,7 @@
 import { Boot } from '@wangeditor/editor';
 import type { EditorInstance, EditorUploadProps } from './types';
 import { ctrlEnterModule, markdownModule } from './module';
-import { deleteVideoMenu } from './menus';
+import { deleteVideoMenu, previewMenu } from './menus';
 
 export function upload(this: EditorInstance, props: EditorUploadProps) {
   const accept = props.accept?.split(',').map((v) => v.trim()) ?? [];
@@ -86,6 +86,7 @@ export function register() {
   if (booted) return;
   Boot.registerModule(markdownModule);
   // Boot.registerModule(ctrlEnterModule);
+  Boot.registerMenu(previewMenu);
   Boot.registerMenu(deleteVideoMenu);
   booted = true;
 }

+ 2 - 0
src/pages/editor/types.ts

@@ -9,6 +9,8 @@ export interface EditorProps {
   disable?: boolean;
   defaultValue?: string;
 
+  previewable?: boolean;
+
   uploadPicture?: EditorUploadProps<'image/*' | `image/${string}`>;
   uploadVideo?: EditorUploadProps<'video/*' | `video/${string}`>;
 

+ 2 - 0
src/pages/editor/useEditor.ts

@@ -103,6 +103,8 @@ export function useEditor(target: MaybeComputedElementRef, props?: EditorProps,
 
       if (props?.uploadVideo) Object.assign(editor.value.getMenuConfig('uploadVideo'), upload.call(editor.value, props.uploadVideo));
       else toolbar.value?.getConfig().excludeKeys?.push('uploadVideo');
+
+      if (!props?.previewable) toolbar.value?.getConfig().excludeKeys?.push('preview');
     }
   });
   tryOnUnmounted(() => {