jpuri / react-draft-wysiwyg

A Wysiwyg editor build on top of ReactJS and DraftJS. https://jpuri.github.io/react-draft-wysiwyg
MIT License
6.41k stars 1.16k forks source link

Tool bar will be scrolled out of frame #1443

Open kikouousya opened 3 months ago

kikouousya commented 3 months ago

The toolbar of this component leaves the view as it scrolls when the content is too long and requires scrolling.

Code here, if you need more info, I will provide.

import React, { useCallback, useEffect, useRef, useState } from "react"
import { CompositeDecorator, ContentBlock, ContentState, EditorState, Modifier, SelectionState } from "draft-js"
import { getSelectionText } from "draftjs-utils"
// import {DraftOffsetKey, DraftEditorLeaf,} from "../../../../../../../node_modules/draft-js/lib"
import DraftOffsetKey from "draft-js/lib/DraftOffsetKey"
// import DraftEditorLeaf from "draft-js/lib/DraftEditorLeaf.react"
import EditorLeaf from "./EditorLeaf"
import { Editor } from "react-draft-wysiwyg"
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css"
import "./HtmlEditor.less"
import {
  CaretDownFilled,
  CaretRightFilled, CloseOutlined,
  CodeOutlined,
  FolderOpenOutlined,
  FolderOutlined,
  PlusOutlined
} from "@ant-design/icons"
import TextArea from "antd/es/input/TextArea"
import { Map, OrderedMap } from "immutable"

import {
  addLink,
  contextMenuItems, getCurrentLnkData, getEntitySelection, getValidSize,
  handleKeyCommand,
  handleReturn,
  myKeyBindingFn,
  selectSubBlocks,
  setSubBlocksCollapsed
} from "./HtmlEditorActions"
import DraftLinkLeaf from "./DraftLinkLeaf"
import { draftBlocks2Html, findLinkEntities, html2DraftBlocks, selectByBlock } from "./utls"
import _ from "lodash"
import { Button, Dropdown, Form, Input, theme } from "antd"
import { FileData } from "../../../../../../../types/interfaces"
import { ipcRenderer } from "../../../../../utils"
import EditorToolBar from "./EditorToolBar"
import DraggableModal from "../../../../BasicComponent/DraggableModal"
import { useTranslation } from "../../../../Utils/locale"
import { useAppSelector } from "../../../../../store"
import { settingState } from "../../../../../store/settings"
import DevInfo from "../../../../Utils/devInfo"

const AEditor = Editor as any

const isBlockOnSelectionEdge = (selection: SelectionState, key: string): boolean => {
  return selection.getAnchorKey() === key || selection.getFocusKey() === key
}

const LinkEditModal = ({editorState, setEditorState, data, setLinkEditData, entitySelection}: {
  data: { entityKey?, pureUrl?, urlData?, url?, open: boolean, text? }
  setLinkEditData, editorState, setEditorState, entitySelection?
}) => {
  const {t} = useTranslation()
  return <DraggableModal
    width={600}
    open={data.open}
    title={t("设置链接")}
    onCancel={e => {
      setLinkEditData({...data, open: false})
    }}
    onOk={e => {
      addLink(editorState, setEditorState, data.text,
        data.url,
        null, entitySelection || getEntitySelection(editorState, data.entityKey))
      setLinkEditData({...data, open: false})
    }}
  >
    <Form
      labelCol={{span: 8}}
      wrapperCol={{span: 24}}
      labelAlign={"left"}
      colon={false}
    >
      <Form.Item label={t("链接")}>
        {(data.url || "").split("||")?.map((url, i) => {
          return <div style={{
            display: "flex",
            flexDirection: "row",
            alignItems: "center",
            justifyContent: "space-between", gap: 3
          }}>
            <Input
              key={i} value={url}
              onChange={e => {
                setLinkEditData({
                  ...data,
                  url: (data.url || "").split("||").map((u, ii) => ii == i ? e.target.value : u).join("||")
                })
              }}
              suffix={< FolderOpenOutlined onClick={async () => {
                const url = await window.api.showOpenDialog({
                  extFilter: [{
                    name: t("插入文件")
                  }], defaultPath: "../../static/models"
                })
                const detail = await window.api.readFileDetail(url)
                if (url) {
                  setLinkEditData({
                    ...data,
                    url: (data.url || "").split("||").map((u, ii) => ii == i ? `file://${url}?${
                      new URLSearchParams({
                        ...getValidSize(detail)
                      })
                    }` : u).join("||")
                  })
                }
              }} />}
            />
            <a>
              <CloseOutlined
                onClick={e => {
                  setLinkEditData({
                    ...data,
                    url: (data.url || "").split("||").filter((u, ii) => ii != i).join("||")
                  })
                }}
              />
            </a>

          </div>
        })}

        <Button
          style={{width: "100%"}}
          onClick={e => {
            setLinkEditData({
              ...data,
              url: (data.url || "") + "||"
            })
          }}
        ><PlusOutlined /></Button>
      </Form.Item>
      <Form.Item label={t("显示文本")}>
        <Input value={data.text}
               onChange={e => {
                 setLinkEditData({
                   ...data,
                   text: e.target.value
                 })
               }}
        ></Input>
      </Form.Item>

    </Form>
    <DevInfo obj={data} />
  </DraggableModal>
}

const HtmlEditor = ({rawData: initialRawData, onChange, parentFolder = null, readOnly}: {
  rawData: string, onChange?: (v: string) => void, parentFolder?: FileData | null,
  readOnly?: boolean,
}) => {
  const [rawData, setRawData] = useState(initialRawData)
  const [editorState, setEditorStateRaw] = useState(EditorState.createEmpty())
  const editorStateRef = useRef(editorState)
  const setEditorState = (editorState) => {
    editorStateRef.current = editorState
    setEditorStateRaw(editorState)
  }
  const [rawTextEditorShowing, setRawTextEditorShowing] = useState(false)
  const [editorScale, setEditorScale] = useState(1)
  const [linkEditData, setLinkEditData] = useState<{
    entityKey?, pureUrl?, urlData?, url?, open: boolean, text?, entitySelection?
  }>({open: false})
  const editorSettings = useAppSelector(state => state.settingState.settings.editor)

  useEffect(() => {
    new Promise(async resolve => {
      const blocks = await html2DraftBlocks(rawData)
      const contentState = ContentState.createFromBlockArray(blocks.toArray() as any)
      const editorState = EditorState.createWithContent(contentState)
      setEditorState(editorState)
      resolve(0)
    })
  }, [])

  const apis = {
    openLinkMenu: (args?) => {
      if (args) {
        const entitySelection = getEntitySelection(editorStateRef.current, args.entityKey)
        entitySelection && setLinkEditData({
          ...args,
          open: true,
          text: getSelectionText(EditorState.forceSelection(editorStateRef.current, entitySelection)),
          entitySelection
        })
      } else {
        const data = getCurrentLnkData(editorState)
        setLinkEditData({
          open: true,
          url: data?.link.target,
          pureUrl: data?.link.target,
          text: data?.link.title || data?.selectionText
        })
      }
    }
  }
  const debounceUpdateEditorState = useCallback(_.debounce(async (rawData) => {
    const blocks = await html2DraftBlocks(rawData, 0)
    const contentState = ContentState.createFromBlockArray(blocks.toArray() as any)
    const editorState = EditorState.createWithContent(contentState)
    setEditorState(editorState)
  }, 500), [])

  const [textInputValue, setTextInputValue] = useState("")
  useEffect(() => {
    ipcRenderer.on("selectBlockByKey", (e, args) => {
      const {blockKey} = args
      const block = editorState.getCurrentContent().getBlockForKey(blockKey)
      const start = 0
      const end = block?.getLength()
      console.log("on selectBlockByKey", {block, start, end, blockKey})
      if (!block) return
      // 创建一个新的selectionState
      const selectionState = new SelectionState({
        anchorKey: blockKey,
        anchorOffset: start,
        focusKey: blockKey,
        focusOffset: end
      })
      // 使用新的selectionState更新editorState
      const newEditorState = EditorState.forceSelection(editorState, selectionState)
      setEditorState(newEditorState)
    })
  }, [])

  const debounceSaveChange = useCallback(_.debounce(async (editorState) => {
    const html = draftBlocks2Html(editorState.getCurrentContent().getBlocksAsArray())
    setTextInputValue(html)
    onChange?.(html)
  }, 3000), [])

  useEffect(() => {
    debounceSaveChange(editorState)
  }, [editorState])
  useEffect(() => {
    debounceUpdateEditorState(rawData)
    return () => {
      debounceUpdateEditorState.cancel()
    }
  }, [rawData])
  const {t} = useTranslation()
  const items = contextMenuItems(editorState, setEditorState, parentFolder)
  // console.log("contextMenuItems", items)
  let olCount = 0
  const BlockComponent = (props: {
    block: ContentBlock,
    decorator: CompositeDecorator,
    contentState: ContentState,
    blockProps: any,
    customStyleMap: any,
    selection: SelectionState,
    onChange: (editorState: EditorState) => void,
    [key: string]: any,
  }) => {
    const blockData = props.block.getData()
    const blockIndent = ((props.indent || 0) + (blockData.get("indent") || 0))

    const blockMarkers = blockData.get("markerTypes")?.split("||") || []
    if (props.block.getType() == "ordered-list-item") {
      olCount++
    } else {
      olCount = 0
    }

    // console.log("blockData", blockData.toJS(), blockMarkers)

    function _renderChildren(): Array<any>{
      const block = props.block
      const blockKey = block.getKey()
      const text = block.getText()
      const lastLeafSet = props.tree?.size - 1
      const hasSelection = isBlockOnSelectionEdge(props.selection, blockKey)

      return props.tree?.map((leafSet, ii) => {
        const leavesForLeafSet = leafSet.get("leaves")
        const lastLeaf = leavesForLeafSet.size - 1
        const leaves = leavesForLeafSet.map((leaf, jj) => {
          const offsetKey = DraftOffsetKey.encode(blockKey, ii, jj)
          const start = leaf.get("start")
          const end = leaf.get("end")
          return <EditorLeaf
            key={offsetKey} offsetKey={offsetKey} block={block} start={start}
            selection={hasSelection ? props.selection : null}
            forceSelection={props.forceSelection} text={text.slice(start, end)}
            styleSet={block.getInlineStyleAt(start)} customStyleMap={props.customStyleMap}
            customStyleFn={props.customStyleFn}
            isLast={ii === lastLeafSet && jj === lastLeaf} />

        }).toArray()

        const decoratorKey = leafSet.get("decoratorKey")
        if (decoratorKey == null) {
          return leaves
        }

        if (!props.decorator) {
          return leaves
        }

        const DecoratorComponent = props.decorator.getComponentForKey(decoratorKey)
        if (!DecoratorComponent) {
          return leaves
        }

        const decoratorProps = props.decorator.getPropsForKey(decoratorKey)
        const decoratorOffsetKey = DraftOffsetKey.encode(blockKey, ii, 0)
        const decoratedText = text.slice(leavesForLeafSet.first().get("start"), leavesForLeafSet.last().get("end"))

        return <DecoratorComponent {...decoratorProps} contentState={props.contentState} decoratedText={decoratedText}
                                   key={decoratorOffsetKey} entityKey={block.getEntityAt(leafSet.get("start"))}
                                   offsetKey={decoratorOffsetKey}>
          {leaves}
        </DecoratorComponent>
      })?.toArray() || []
    }

    return (<div style={{
      display: blockData.get("hidden") ? "none" : "block"
    }}>
      <div
        className={`editorBlock`}
        style={{
          display: "flex", alignItems: "end",
          marginLeft: blockMarkers?.length ? `calc(${blockIndent * 0.35 + "in"} - ${20 * blockMarkers?.length}px)` : blockIndent * 0.35 + "in",
          cursor: "text"
        }}
      >

        <span
          style={{fontSize: 16, cursor: "pointer", alignSelf: "center"}}
          className={blockData.get("collapsed") ? "" : "show-when-hover"}
          onClick={e => {
            if (e.ctrlKey) {
              selectSubBlocks(editorState, setEditorState, [props.block])
            } else {
              setSubBlocksCollapsed(editorState, setEditorState, [props.block])
            }
          }}

          onDoubleClick={(e) => {
            console.log("on caret click", e)
            const blocksAfter: OrderedMap<string, ContentBlock> = editorState.getCurrentContent().getBlockMap().skipUntil(block => block === props.block).rest() as any
            const firstIdentIdentical = blocksAfter.find(block => block?.getData()?.get("indent") == blockData?.get("indent"))
            const blockToCollapse = blocksAfter.takeUntil(block => block === firstIdentIdentical)
            // set Data.hidden
            let newContentState = blockToCollapse.reduce((contentState, b) => {
              return Modifier.setBlockData(contentState!, selectByBlock(b!), b!.getData().set("hidden", !blockData.get("collapsed")))
            }, editorState.getCurrentContent())
            newContentState = Modifier.setBlockData(newContentState, selectByBlock(props.block), blockData.set("collapsed", !blockData.get("collapsed")))
            const newEditorState = EditorState.push(editorState, newContentState, "change-block-data")
            setEditorState(newEditorState)

            console.log("blocksAfter", blocksAfter)
          }}>
          {blockData.get("collapsed") ? <CaretRightFilled /> : <CaretDownFilled />}
        </span>
        {blockMarkers.map(m => <span style={{width: 20}}>
          <i style={{
            fontSize: 16, cursor: "pointer", alignSelf: "center",
            ...m?.split("?")?.[1] ? {color: m?.split("?")?.[1]} : {}
          }}
             className={`bi ${m?.split("?")?.[0]}`} /></span>
        )}
        {props.block.getType() == "unordered-list-item" &&
          <span style={{width: "0.35in"}}>
            <i className="bi bi-dot"></i>
          </span>}
        {props.block.getType() == "ordered-list-item" &&
          <span style={{width: "0.35in"}}>
            {olCount}.
          </span>}

        {_renderChildren()}

      </div>
    </div>)
  }

  const blockRendererFn = (block) => {
    return {
      component: BlockComponent, editable: true, props: {olCount}
    }
  }

  const DraftLink = (props) => {
    return <DraftLinkLeaf
      {...props}
      editorStateRef={editorStateRef}
      setEditorState={setEditorState}
      apis={apis}
    />
  }

  return (

    <div className={"html-editor"}>
      {/*If I click something on this toolbar, OnFucusout will trriger*/}
      {/*{!readOnly && <EditorToolBar editorState={editorState} setEditorState={setEditorState} apis={apis}*/}
      {/*                             toggleRawShowing={() => setRawTextEditorShowing(!rawTextEditorShowing)} />}*/}

      <div
        style={{
          display: "flex",
          // flexDirection: "column",
          height: "100%", // Or whatever height you want
          width: "100%",
          maxHeight: "100vh",
          overflow: "auto"
        }}
      >
        <div
          style={{
            flex: 1,
            scale: editorScale
          }}
          onWheel={e => {
            console.log("on editor Wheel", e)
            if (e.ctrlKey) {
              if (e.deltaY > 0) {
                setEditorScale(editorScale * 1.05)
              } else {
                setEditorScale(editorScale * 0.95)
              }

            }
          }}
        >
          <Dropdown
            menu={{items: items}}
            trigger={["contextMenu"]}
            // open={rightClickContext}
          >
            <div
              onBlur={e => {
                console.log("focusout div")
                e.preventDefault()
                e.stopPropagation()
              }}
            >
              <Editor
                className={"editor-area"}
                // toolbarHidden={true}
                toolbar={{
                  options: []
                }}
                editorState={editorState}
                wrapperClassName="demo-wrapper"
                editorClassName="demo-editor"
                onEditorStateChange={setEditorState}
                handleKeyCommand={(c, e) => handleKeyCommand(c, e, setEditorState, parentFolder, apis, editorSettings)}
                keyBindingFn={(e) => myKeyBindingFn(e, apis)}
                // toolbarCustomButtons={[<LineHeightControl />]} // 添加自定义按钮
                blockRendererFn={blockRendererFn}
                // onChange={handleChange}
                handleReturn={(c, e) => handleReturn(c, e, setEditorState)}
                // blockRenderMap={BlockRenderMap}
                // readOnly={readOnly}
                focusout={e => {
                  console.log("focusout AEditor")
                  e.preventDefault()
                  e.stopPropagation()
                }}
                onBlur={e => {
                  console.log("focusout AEditor")
                  e.preventDefault()
                  e.stopPropagation()
                }}
                editorStyle={{
                  flex: 1,
                  overflow: "auto"
                }}
                customDecorators={[
                  {
                    strategy: findLinkEntities,
                    component: DraftLink
                  }
                ]}
                toolbarCustomButtons={[(<EditorToolBar editorState={editorState} setEditorState={setEditorState} apis={apis}
                                                       toggleRawShowing={() => setRawTextEditorShowing(!rawTextEditorShowing)} />)]}
              />
              {parentFolder && <div style={{height: "50vh"}} />}
            </div>

          </Dropdown>
        </div>

        {rawTextEditorShowing && <TextArea
          style={{
            height: 300,
            flex: 0.8
          }}
          autoSize
          value={textInputValue}
          onChange={e => {
            setRawData(e.target.value)
            setTextInputValue(e.target.value)
            onChange?.(e.target.value)
          }}
        />}
        <LinkEditModal data={linkEditData} setLinkEditData={setLinkEditData} editorState={editorState}
                       setEditorState={setEditorStateRaw} />
      </div>
    </div>
  )
}

export default HtmlEditor