diff --git a/packages/antd/lib/index.tsx b/packages/antd/lib/index.tsx index da8789f..58d558b 100755 --- a/packages/antd/lib/index.tsx +++ b/packages/antd/lib/index.tsx @@ -64,6 +64,7 @@ export const DefaultToolbar = [ 'line-height', 'divider', 'img', + 'video', 'link', 'blockquote', 'hr', @@ -81,7 +82,6 @@ export default forwardRef( const locales = context.locales ? context.locales : DefaultLocales; return { - iconScriptUrl: '//at.alicdn.com/t/c/font_3062978_igshjiflyft.js', ...context, locales: mergeLocalteFromPlugins(locales, plugins), plugins, diff --git a/packages/component/lib/components/icon.tsx b/packages/component/lib/components/icon.tsx index 94c2300..b524db8 100644 --- a/packages/component/lib/components/icon.tsx +++ b/packages/component/lib/components/icon.tsx @@ -1,5 +1,5 @@ -import { ConfigContext } from "@dslate/core"; -import { useContext, useEffect } from "react"; +import { ConfigContext } from '@dslate/core'; +import { useContext, useEffect } from 'react'; const inited: string[] = []; @@ -12,17 +12,20 @@ const Icon = (props: IconProps) => { const { iconScriptUrl } = useContext(ConfigContext); const initScript = () => { + if (!iconScriptUrl) return; let urls: any = iconScriptUrl; - if (typeof urls === "string") urls = [urls]; - - for (const url of urls) { - if (inited.includes(url)) continue; - let script = document.createElement("script"); - script.type = "text/javascript"; - script.async = true; - script.src = url; - document.head.appendChild(script); - inited.push(url); + if (typeof urls === 'string') urls = [urls]; + + if (urls?.length) { + for (const url of urls) { + if (inited.includes(url)) continue; + let script = document.createElement('script'); + script.type = 'text/javascript'; + script.async = true; + script.src = url; + document.head.appendChild(script); + inited.push(url); + } } }; @@ -33,11 +36,11 @@ const Icon = (props: IconProps) => { return ( diff --git a/packages/component/lib/iconfont.js b/packages/component/lib/iconfont.js new file mode 100644 index 0000000..130e6c3 --- /dev/null +++ b/packages/component/lib/iconfont.js @@ -0,0 +1,70 @@ +(window._iconfont_svg_string_3062978 = + ''), + ((c) => { + var l = (h = (h = document.getElementsByTagName('script'))[ + h.length - 1 + ]).getAttribute('data-injectcss'), + h = h.getAttribute('data-disable-injectsvg'); + if (!h) { + var a, + o, + i, + t, + s, + v = function (l, h) { + h.parentNode.insertBefore(l, h); + }; + if (l && !c.__iconfont__svg__cssinject__) { + c.__iconfont__svg__cssinject__ = !0; + try { + document.write( + '', + ); + } catch (l) { + console && console.log(l); + } + } + (a = function () { + var l, + h = document.createElement('div'); + (h.innerHTML = c._iconfont_svg_string_3062978), + (h = h.getElementsByTagName('svg')[0]) && + (h.setAttribute('aria-hidden', 'true'), + (h.style.position = 'absolute'), + (h.style.width = 0), + (h.style.height = 0), + (h.style.overflow = 'hidden'), + (h = h), + (l = document.body).firstChild + ? v(h, l.firstChild) + : l.appendChild(h)); + }), + document.addEventListener + ? ~['complete', 'loaded', 'interactive'].indexOf(document.readyState) + ? setTimeout(a, 0) + : ((o = function () { + document.removeEventListener('DOMContentLoaded', o, !1), a(); + }), + document.addEventListener('DOMContentLoaded', o, !1)) + : document.attachEvent && + ((i = a), + (t = c.document), + (s = !1), + m(), + (t.onreadystatechange = function () { + 'complete' == t.readyState && + ((t.onreadystatechange = null), z()); + })); + } + function z() { + s || ((s = !0), i()); + } + function m() { + try { + t.documentElement.doScroll('left'); + } catch (l) { + return void setTimeout(m, 50); + } + z(); + } + })(window); diff --git a/packages/component/lib/index.tsx b/packages/component/lib/index.tsx index bd17791..99e5280 100755 --- a/packages/component/lib/index.tsx +++ b/packages/component/lib/index.tsx @@ -1,2 +1,3 @@ -export * from "./components"; -export * from "./element"; +import './iconfont.js'; +export * from './components'; +export * from './element'; diff --git a/packages/core/lib/utils/base64file.ts b/packages/core/lib/utils/base64file.ts deleted file mode 100755 index 3639d2a..0000000 --- a/packages/core/lib/utils/base64file.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { UploadFunc, UploadRequestOption } from "../typing"; - -export const base64file: UploadFunc = (option: UploadRequestOption) => { - const reader: FileReader = new FileReader(); - reader.addEventListener( - "load", - () => { - option.onSuccess?.({ url: reader.result as string }); - }, - false - ); - reader.readAsDataURL(option.file); -}; diff --git a/packages/core/lib/utils/file.ts b/packages/core/lib/utils/file.ts new file mode 100755 index 0000000..6d6cbde --- /dev/null +++ b/packages/core/lib/utils/file.ts @@ -0,0 +1,29 @@ +import type { UploadFunc, UploadRequestOption } from '../typing'; + +export const base64file: UploadFunc = (option: UploadRequestOption) => { + const reader: FileReader = new FileReader(); + reader.addEventListener( + 'load', + () => { + option.onSuccess?.({ url: reader.result as string }); + }, + false, + ); + reader.readAsDataURL(option.file); +}; + +export const blobfile: UploadFunc = (option: UploadRequestOption) => { + const reader: FileReader = new FileReader(); + reader.addEventListener( + 'load', + (event) => { + let blob = new Blob([event?.target?.result as any], { + type: option.file.type, + }); + window.URL = window.URL || window.webkitURL; + option.onSuccess?.({ url: window.URL.createObjectURL(blob) }); + }, + false, + ); + reader.readAsDataURL(option.file); +}; diff --git a/packages/core/lib/utils/index.ts b/packages/core/lib/utils/index.ts index a20d559..791e3de 100755 --- a/packages/core/lib/utils/index.ts +++ b/packages/core/lib/utils/index.ts @@ -1,10 +1,16 @@ -export { mergeStyle, style2string } from './mergeStyle'; +export { + clearBlockProps, + getBlockProps, + isBlockActive, + setBlockProps, + toggleBlock, +} from './block'; +export { base64file, blobfile } from './file'; export { get } from './get'; -export { getTextProps, setTextProps, toggleTextProps } from './text'; -export { toggleBlock, getBlockProps, setBlockProps, isBlockActive, clearBlockProps } from './block'; -export { withPlugins } from './withPlugins'; +export { isEmpty } from './isEmpty'; export { isStart } from './isStart'; -export { promiseUploadFunc } from './promiseUploadFunc'; -export { base64file } from './base64file'; export { mergeLocalteFromPlugins } from './mergeLocalteFromPlugins'; -export { isEmpty } from './isEmpty'; +export { mergeStyle, style2string } from './mergeStyle'; +export { promiseUploadFunc } from './promiseUploadFunc'; +export { getTextProps, setTextProps, toggleTextProps } from './text'; +export { withPlugins } from './withPlugins'; diff --git a/packages/core/lib/utils/promiseUploadFunc.ts b/packages/core/lib/utils/promiseUploadFunc.ts index a56545b..4414c80 100755 --- a/packages/core/lib/utils/promiseUploadFunc.ts +++ b/packages/core/lib/utils/promiseUploadFunc.ts @@ -1,13 +1,15 @@ -import type { UploadFunc, UploadRequestOption } from "../typing"; -import { base64file } from "."; +import { base64file, blobfile } from '.'; +import type { UploadFunc, UploadRequestOption } from '../typing'; export const promiseUploadFunc = ( options: UploadRequestOption, customUploadRequest?: UploadFunc, - setPercent?: (p: number) => void + setPercent?: (p: number) => void, + defaultType: 'dataurl' | 'bloburl' | false = 'dataurl', ) => { const { onProgress, onError, onSuccess } = options; return new Promise<{ url?: string }>((resolve, reject) => { + setPercent?.(1); const args = { ...options, onProgress: (e: { percent?: number }) => { @@ -29,7 +31,12 @@ export const promiseUploadFunc = ( if (customUploadRequest) { customUploadRequest(args); } else { - base64file(args); + if (defaultType === 'dataurl') base64file(args); + if (defaultType === 'bloburl') blobfile(args); + alert('not support upload function'); + reject('not support upload function'); + onError?.(new Error('not support upload function')); + setPercent?.(-1); } }); }; diff --git a/packages/plugin/lib/index.tsx b/packages/plugin/lib/index.tsx index 695de53..b1de714 100755 --- a/packages/plugin/lib/index.tsx +++ b/packages/plugin/lib/index.tsx @@ -17,7 +17,7 @@ import { ListPlugin } from './plugins/list'; import { ParagraphPlugin } from './plugins/paragraph'; import { TextAlignPlugin } from './plugins/text-align'; import { TodoListPlugin } from './plugins/todo-list'; - +import { VideoPlugin } from './plugins/video'; export default { HistoryPlugin, ClearPlugin, @@ -33,6 +33,7 @@ export default { TextIndentPlugin, TodoListPlugin, ImgPlugin, + VideoPlugin, LinkPlugin, BlockquotePlugin, HrPlugin, diff --git a/packages/plugin/lib/plugins/video/index.tsx b/packages/plugin/lib/plugins/video/index.tsx new file mode 100755 index 0000000..1b6cb21 --- /dev/null +++ b/packages/plugin/lib/plugins/video/index.tsx @@ -0,0 +1,211 @@ +import { Editor, Path, Transforms } from 'slate'; +import { useSlate } from 'slate-react'; + +import { Icon, Toolbar } from '@dslate/component'; + +import { Locales, useMessage, usePlugin } from '@dslate/core'; +import Video from './video'; + +import type { CSSProperties } from 'react'; +import type { Descendant } from 'slate'; + +import type { DSlatePlugin, RenderElementPropsWithStyle } from '@dslate/core'; + +const TYPE = 'video'; + +const renderElement = (props: RenderElementPropsWithStyle) => { + return ; +}; + +const withPlugin = (editor: Editor) => { + const { insertBreak } = editor; + + editor.insertBreak = () => { + // console.log("editor.selection", editor.selection); + + if (editor.selection) { + const [ele] = Editor.nodes(editor, { + match: (n) => n.type === TYPE, + }); + + // console.log(ele); + + if (!!ele) { + const [, elepath] = ele; + Editor.withoutNormalizing(editor, () => { + Transforms.insertNodes( + editor, + { + type: editor.defaultElement, + children: [{ text: '' }], + } as Descendant, + { + at: Path.next(elepath), + }, + ); + + Transforms.select(editor, Path.next(elepath)); + }); + return; + } + } + insertBreak(); + }; + + return editor; +}; + +const renderStyle = (node: Descendant) => { + if (node.type === TYPE) { + const style: CSSProperties = {}; + if (node.videoWidth) style.width = node.videoWidth; + if (node.videoHeight) style.height = node.videoHeight; + for (const position of ['Left', 'Top', 'Bottom', 'Right']) { + const cssKey = `margin${position}` as + | 'marginLeft' + | 'marginTop' + | 'marginBottom' + | 'marginRight'; + if (node.margin?.[position.toLowerCase()]) { + style[cssKey] = `${node.margin[position.toLowerCase()]}px`; + } else { + style[cssKey] = undefined; + } + } + + if (node.maxWidth) { + if (String(node.maxWidth).endsWith('%')) { + style.maxWidth = node.maxWidth; + } else { + style.maxWidth = `${node.maxWidth}px`; + } + } + + return style; + } + return {}; +}; + +const ToolbarButton = () => { + const editor = useSlate(); + const getMessage = useMessage(); + const { disabled } = usePlugin(); + + const insertVideo = async () => { + if (disabled) return; + // const { url } = await promiseUploadFunc( + // { + // onProgress: option.onProgress, + // onError: option.onError, + // onSuccess: option.onSuccess, + // file: option.file as File, + // }, + // customUploadRequest, + // setPercent + // ); + Transforms.insertNodes(editor, { + type: TYPE, + url: '', + children: [{ text: '' }], + videoWidth: '100%', + videoHeight: 'auto', + }); + }; + + return ( + } + onClick={() => { + insertVideo(); + }} + /> + ); +}; + +const VideoPlugin: DSlatePlugin = { + type: TYPE, + nodeType: 'element', + toolbar: ToolbarButton, + isVoid: true, + renderElement, + renderStyle, + props: { + loadingStyle: { + minHeight: 150, + minWidth: 300, + } as CSSProperties, + maxWidth: false, + defaultWidth: undefined, + loadingText: '视频加载中...', + }, + locale: [ + { + locale: Locales.zhCN, + tooltip: '插入视频', + upload: '上传视频', + confirm: '确认', + height: '高', + width: '宽', + loading: '视频加载中', + remove: '删除', + float: '对齐方式', + ['float-left']: '左对齐', + ['float-right']: '右对齐', + ['float-center']: '居中', + margin: '外边距', + top: '上', + left: '左', + right: '右', + bottom: '下', + ['empty-url']: '未设置视频', + }, + { + locale: Locales.enUS, + tooltip: 'insert video', + upload: 'upload video', + confirm: 'confirm', + height: 'height', + width: 'width', + loading: 'loading', + remove: 'remove', + float: 'align', + ['float-left']: 'left', + ['float-right']: 'right', + ['float-center']: 'center', + margin: 'margin', + top: 'top', + left: 'left', + right: 'right', + bottom: 'bottom', + ['empty-url']: 'empty url', + }, + ], + withPlugin, + serialize: (element, props) => { + const style = []; + if (props?.style) style.push(props.style); + return `
`; + }, + serializeWeapp: () => { + return { + type: 'node', + name: 'div', + attrs: { + style: `text-align: center; color: red;`, + }, + children: [ + { + type: 'text', + text: 'weapp not support video', + }, + ], + }; + }, +}; + +export { VideoPlugin }; diff --git a/packages/plugin/lib/plugins/video/video.tsx b/packages/plugin/lib/plugins/video/video.tsx new file mode 100755 index 0000000..b11dd07 --- /dev/null +++ b/packages/plugin/lib/plugins/video/video.tsx @@ -0,0 +1,514 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { Icon, Input, InputNumber, Popover, Toolbar } from '@dslate/component'; +import type { RenderElementPropsWithStyle } from '@dslate/core'; +import { + promiseUploadFunc, + useConfig, + useMessage, + usePlugin, + usePluginHelper, +} from '@dslate/core'; +import Upload from 'rc-upload'; +import type { UploadRequestOption } from 'rc-upload/lib/interface'; +import { useEffect, useRef, useState } from 'react'; +import { Transforms } from 'slate'; +import { ReactEditor, useSelected, useSlate } from 'slate-react'; + +const prefixCls = 'dslate-img-element'; + +type Size = { width: string; height: string }; + +const resize = ( + origin: Size, + fixBy: 'width' | 'height', + value: string, +): Size => { + if (String(value).endsWith('%')) { + return { + width: fixBy === 'width' ? value : 'auto', + height: fixBy === 'height' ? value : 'auto', + }; + } + + const p = Number(origin.width) / Number(origin.height); + + if (isNaN(Number(value)) && Number(value) <= 0) return origin; + + const valueNumber = Number(value); + + return { + width: String( + fixBy === 'height' ? Math.floor(valueNumber * p) : valueNumber, + ), + height: String( + fixBy === 'width' ? Math.floor(valueNumber / p) : valueNumber, + ), + }; +}; + +const Video = ({ + attributes, + children, + element, + style, +}: RenderElementPropsWithStyle) => { + const { setPercent } = usePluginHelper(); + const { customUploadRequest } = useConfig(); + const { props } = usePlugin(); + + const getMessage = useMessage(); + + const video = useRef(null); + const [loading, setLoading] = useState(false); + + const [editable, setEditable] = useState<{ + width: string; + height: string; + maxWidth: string; + url: string; + }>({ + width: '', + height: '', + maxWidth: '', + url: '', + }); + + useEffect(() => { + if (element.url.indexOf(';base64,') === -1) { + setLoading(true); + } + }, [element.url]); + + const selected = useSelected(); + const editor = useSlate(); + const path = ReactEditor.findPath(editor, element); + + const updateSize = (target: any) => { + const width = isNaN(Number(target.width)) + ? target.width + : Number(target.width); + const height = isNaN(Number(target.height)) + ? target.height + : Number(target.height); + Transforms.setNodes( + editor, + { + videoHeight: height, + videoWidth: width, + }, + { + at: path, + }, + ); + }; + + const updateMargin = (position: string, margin: number) => { + Transforms.setNodes( + editor, + { + margin: { + ...(element.margin || {}), + [position]: margin, + }, + }, + { + at: path, + }, + ); + }; + + const updateAlign = (align: string) => { + Transforms.setNodes( + editor, + { + align, + }, + { + at: path, + }, + ); + }; + + const updateEditableSizeEnd = () => { + updateSize(editable); + }; + + const updateEditableSize = (key: 'width' | 'height', value: string) => { + // 等比缩放 + const width = String(video.current?.videoWidth ?? 1); + const height = String(video.current?.videoHeight ?? 1); + const nSize = resize({ width, height }, key, value); + setEditable((pre) => ({ + ...pre, + ...nSize, + })); + }; + + useEffect(() => { + if (selected) { + /** + * 选中状态下,优先同步参数宽度,其次同步实际宽高到编辑框 + */ + const width = element.videoWidth ?? video.current?.offsetWidth ?? ''; + const height = element.videoHeight ?? video.current?.offsetHeight ?? ''; + setEditable((pre) => { + return { + ...pre, + width: width, + height: height, + url: element.url, + maxWidth: element.maxWidth, + }; + }); + } + }, [selected, element.videoWidth, element.videoHeight]); + + /** + * 加载完毕后初始化参数 + */ + const onVideoLoad = () => { + if (element.videoWidth && element.videoHeight) { + setEditable((pre) => { + return { + ...pre, + width: element.videoWidth, + height: element.videoHeight, + }; + }); + + updateSize({ + width: element.videoWidth, + height: element.videoHeight, + }); + + setLoading(false); + } else { + const defaultWidth = props?.defaultWidth; + let offsetWidth = String(video.current?.offsetWidth ?? ''); + let offsetHeight = String(video.current?.offsetHeight ?? ''); + + if (!offsetWidth || !offsetHeight) return; + + let width = offsetWidth; + let height = offsetHeight; + + if (defaultWidth) { + ({ width, height } = resize({ width, height }, 'width', defaultWidth)); + } + + setEditable((pre) => { + return { + ...pre, + width, + height, + }; + }); + + updateSize({ + width, + height, + }); + + setLoading(false); + } + }; + + const updateUrl = async (option: UploadRequestOption) => { + const { url } = await promiseUploadFunc( + { + onProgress: option.onProgress, + onError: option.onError, + onSuccess: option.onSuccess, + file: option.file as File, + }, + customUploadRequest, + setPercent, + false, + ); + + if (!url) return; + Transforms.setNodes( + editor, + { + url, + videoWidth: '100%', + videoHeight: 'auto', + }, + { + at: path, + }, + ); + + setEditable({ + width: '100%', + height: 'auto', + url: url, + maxWidth: '', + }); + + updateSize({ + width: '100%', + height: 'auto', + }); + }; + + return ( +
+ {children} +
+ +
+ {getMessage('URL', 'URL')} + { + setEditable((pre) => ({ + ...pre, + url, + })); + Transforms.setNodes( + editor, + { + url, + }, + { + at: path, + }, + ); + }} + /> + { + updateUrl(option); + }} + > + } + /> + +
+
+ {getMessage('width', '宽')} + { + updateEditableSize('width', number); + }} + onKeyPress={(e: any) => { + if (e.key === 'Enter') { + updateEditableSizeEnd(); + } + }} + /> + {getMessage('height', '高')} + { + updateEditableSize('height', String(number)); + }} + /> + + {getMessage('max-width', '宽上限')} + { + setEditable((pre) => ({ + ...pre, + maxWidth, + })); + + Transforms.setNodes( + editor, + { + maxWidth, + }, + { + at: path, + }, + ); + }} + /> + + { + Transforms.removeNodes(editor, { + at: path, + }); + }} + icon={ + + } + /> +
+ +
+ {['20%', '40%', '60%', '80%', '100%'].map((p) => ( + { + updateEditableSize('width', p); + updateSize({ + width: p, + height: 'auto', + }); + }} + style={{ + backgroundColor: '#eee', + borderRadius: '8px', + fontSize: '12px', + height: '22px', + }} + key={p} + > + {p} + + ))} +
+ +
+ {getMessage('float', '对齐方式')} + + { + updateAlign('left'); + }} + icon={} + /> + + { + updateAlign('center'); + }} + icon={} + /> + + { + updateAlign('right'); + }} + icon={} + /> +
+ +
+ {getMessage('margin', '外边距')} + { + updateMargin('top', number as number); + }} + placeholder={getMessage('top', '上')} + /> + { + updateMargin('right', number as number); + }} + placeholder={getMessage('right', '右')} + /> + { + updateMargin('bottom', number as number); + }} + placeholder={getMessage('bottom', '下')} + /> + { + updateMargin('left', number as number); + }} + placeholder={getMessage('left', '左')} + /> +
+
+ } + > +
+
+ +
+ + ); +}; + +export default Video; diff --git a/packages/semi/lib/index.tsx b/packages/semi/lib/index.tsx index c269895..592e160 100755 --- a/packages/semi/lib/index.tsx +++ b/packages/semi/lib/index.tsx @@ -62,6 +62,7 @@ export const DefaultToolbar = [ 'line-height', 'divider', 'img', + 'video', 'link', 'blockquote', 'hr', @@ -79,7 +80,6 @@ export default forwardRef( const locales = context.locales ? context.locales : DefaultLocales; return { - iconScriptUrl: '//at.alicdn.com/t/c/font_3062978_igshjiflyft.js', ...context, locales: mergeLocalteFromPlugins(locales, plugins), plugins,