代码编辑组件

文章说明

拖了很久,总算是自己写了一个简单的代码编辑组件,虽然还有不少的bug,真的很难写,在写的过程中感觉自己的前端技术根本不够用,好像总是方案不够好;目前写出了这个效果,等待后续学习别的现有产品,再慢慢补充

采用div的设置可编辑属性 contenteditable=“true”,然后结合 highlight的代码高亮,效果还不错;最开始相加的代码输入提示,以为不难实现,但是真的写起来,总是没有办法很好的实现那个输入面板的位置控制;索性就暂时不加输入提示,然后搜索功能,也是有不小的难点;目前仍然存在着一些bug待修复,但是可以先作为小demo试用一下

目前算是完成第一阶段,虽然组件的功能不是很完善,但是基本的代码高亮和搜索功能也有了,算是差强人意啦

核心代码

输入组件

<script setup>
import {onMounted, reactive} from "vue";
import Search from "@/components/Search.vue";
import hljs from 'highlight.js';
import "highlight.js/styles/idea.css";
import {useSearchStore} from "@/stores/search";
import {appendDom} from "@/utils";

const data = reactive({
  lineNumber: [1],
  language: "html"
});

const search = useSearchStore();

function refresh() {
  const height = inputElem.scrollHeight;
  const number = Math.ceil(height / 21);
  if (number !== data.lineNumber.length) {
    data.lineNumber = [];
    for (let i = 0; i < number; i++) {
      data.lineNumber.push(i + 1);
    }
  }

  if (search.showSearch) {
    search.search(false);
  }
}

let leftLineNumberContainer;
let rightInputArea;
let inputElem;

function syncScroll(event) {
  if (event.target === leftLineNumberContainer) {
    rightInputArea.scrollTo({
      top: event.target.scrollTop,
    });
  }
  if (event.target === rightInputArea) {
    leftLineNumberContainer.scrollTo({
      top: event.target.scrollTop,
    });
  }
}

function inputFocus() {
  inputElem.focus();
  const range = document.createRange();
  range.selectNodeContents(inputElem);
  range.collapse(false);
  const sel = window.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);

  if (!leftLineNumberContainer) {
    leftLineNumberContainer = document.getElementsByClassName("left-line-number-container")[0];
  }
  if (!rightInputArea) {
    rightInputArea = document.getElementsByClassName("right-input-area")[0];
  }
  leftLineNumberContainer.scrollTo({
    top: rightInputArea.scrollHeight,
  });
  rightInputArea.scrollTo({
    top: rightInputArea.scrollHeight,
  });
}

onMounted(() => {
  inputElem = document.getElementsByClassName("input-elem")[0];
  rightInputArea = document.getElementsByClassName("right-input-area")[0];
  search.rightInputArea = rightInputArea;
  search.inputElem = inputElem;
  search.language = data.language;
  search.refresh = refresh;
  inputFocus();
});

function getPasteData(event) {
  const clipData = event.clipboardData || window.clipboardData
  const value = clipData.getData('text/plain');

  let highlightedCode = hljs.highlight(value, {
    language: data.language
  }).value;
  const container = document.createElement("span");
  container.innerHTML = highlightedCode;

  function wrapTextNodesInSpan(element) {
    for (let i = 0; i < element.childNodes.length; i++) {
      const child = element.childNodes[i];

      if (child.nodeType === Node.TEXT_NODE) {
        const span = document.createElement('span');
        span.textContent = child.nodeValue;

        element.insertBefore(span, child);
        element.removeChild(child);
      } else if (child.nodeType === Node.ELEMENT_NODE) {
        wrapTextNodesInSpan(child);
      }
    }
  }

  wrapTextNodesInSpan(container);

  appendDom(rightInputArea, container);

  refresh();
}

function keydown(event) {
  search.keydown(event);
  if (event.key === "Tab") {
    event.preventDefault();
    appendContent("  ");
  }
  if (event.ctrlKey) {
    return;
  }
  if (event.key === "Enter" || event.key === "Backspace" || event.key === "Delete") {
    setTimeout(() => {
      refresh();
    }, 10);
    return;
  }
  if (isAlphaNumeric(event.key)) {
    event.preventDefault();
    appendContent(event.key);
    refresh();
  }
}

function isAlphaNumeric(key) {
  return /^[a-zA-Z0-9]$/.test(key);
}

function appendContent(content) {
  const span = document.createElement("span");
  span.textContent = content;
  appendDom(rightInputArea, span, false);
}
</script>

<template>
  <div class="editor-container">
    <Search/>
    <div style="display: flex" :style="{ height: search.showSearch ? 'calc(100% - 30px)' : '100%' }">
      <div class="left-line-number-container" @scroll="syncScroll($event)">
        <template v-for="(item, index) in data.lineNumber" :key="index">
          <p>{{ item }}</p>
        </template>
      </div>
      <div class="right-input-area" @scroll="syncScroll($event)" @click.self="inputFocus">
        <div class="input-elem" :contenteditable="!search.showSearch" @keydown="keydown($event)"
             @paste.prevent="getPasteData($event)"></div>
      </div>
    </div>
  </div>
</template>

<style lang="scss">
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

.hljs-tag {
  background-color: transparent !important;
}

.hljs-attribute, .hljs-number, .hljs-regexp, .hljs-link {
  font-weight: normal;
  color: #3931c5;
}

.hljs-section, .hljs-name, .hljs-literal, .hljs-keyword, .hljs-selector-tag, .hljs-type, .hljs-selector-id, .hljs-selector-class {
  font-weight: normal;
  color: #3931c5;
}

.editor-container {
  width: 100vw;
  height: 100vh;
  background-color: #ffffff;
  padding-left: 200px;

  .left-line-number-container {
    min-width: 60px;
    background-color: #f2f2f2;
    border: 1px solid #d4d4d4;
    padding: 4px 4px 230px;
    overflow: auto;

    &::-webkit-scrollbar {
      height: 0;
      width: 0;
    }

    p {
      width: fit-content;
      color: #adadad;
      font-size: 14px;
      line-height: 1.5;
      font-family: "JetBrains Mono", sans-serif;
      word-spacing: 0.2rem;
      text-align: right;
    }
  }

  .right-input-area {
    flex: 1;
    border: 1px solid #d4d4d4;
    border-left: none;
    padding: 4px 4px 230px;
    overflow: auto;
    position: relative;
    cursor: default;

    #default-cursor {
      width: 0;
      height: 0;
      display: inline-block;
    }

    &::-webkit-scrollbar {
      height: 10px;
      width: 10px;
    }

    &::-webkit-scrollbar-thumb {
      background-color: #e2e2e2;
      border-radius: 0;
    }

    &::-webkit-scrollbar-track {
      background-color: transparent;
    }

    .input-elem {
      border: none;
      outline: none;
      height: fit-content;
      min-width: 100%;
      font-size: 14px;
      color: #080808;
      min-height: 21px;
      line-height: 21px;
      font-family: "JetBrains Mono", sans-serif;
      word-spacing: 0.2rem;
      white-space: pre;
      word-break: break-all;

      &::selection {
        background-color: #a6d2ff;
      }

      .highlight-item {
        background-color: #ffe959;
      }

      .current-highlight-item {
        background-color: #a6d2ff;
      }

      pre {
        font-family: "JetBrains Mono", sans-serif;

        &::selection {
          background-color: #a6d2ff;
        }
      }
    }
  }
}
</style>

搜索组件

<script setup>
import {useSearchStore} from "@/stores/search";

const search = useSearchStore();

function changeCase() {
  search.caseSelected = !search.caseSelected;
  search.search();
}

function changeWord() {
  search.wordSelected = !search.wordSelected;
  search.search();
}

function close() {
  search.currentIndex = 1;
  search.showSearch = false;
  search.recover();
}

function last() {
  search.beginSearch = true;
  if (search.currentIndex === 1) {
    search.currentIndex = search.searchResult.length;
  } else {
    search.currentIndex--;
  }
  search.search();
}

function next() {
  search.beginSearch = true;
  if (search.currentIndex === search.searchResult.length) {
    search.currentIndex = 1;
  } else {
    search.currentIndex++;
  }
  search.search();
}
</script>

<template>
  <div v-show="search.showSearch" class="search-container" @click.stop>
    🔍
    <input v-model="search.searchText" @input="search.search" id="input"/>
    <div :class="search.caseSelected ? ' active-case ' : ''" class="case" @click="changeCase">Cc
    </div>
    <div :class="search.wordSelected ? ' active-word ' : ''" class="word" @click="changeWord">W
    </div>
    <div class="result">
      <template v-if="!search.beginSearch || search.searchResult.length === 0">{{ search.searchResult.length }}
        results
      </template>
      <template v-if="search.beginSearch && search.searchResult.length > 0">{{
          search.currentIndex
        }}/{{ search.searchResult.length }}
      </template>
    </div>
    <div class="last" @click="last"></div>
    <div class="next" @click="next"></div>
    <div class="close" @click="close">×</div>
  </div>
</template>

<style lang="scss" scoped>
.search-container {
  width: 100%;
  height: 30px;
  border: 1px solid #d1d1d1;
  border-bottom: none;
  display: flex;
  align-items: center;
  user-select: none;

  input {
    border: none;
    outline: none;
    width: 350px;
    height: 28px;
    margin: 10px;
  }

  .case, .word {
    background-color: #ffffff;
    color: #bfc5c8;
    width: 25px;
    height: 25px;
    padding: 3px;
    margin-right: 4px;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 14px;
    font-weight: 600;
    font-family: "JetBrains Mono", sans-serif;
    border-radius: 5px;
    cursor: default;

    &:hover {
      background-color: #dfdfdf;
      color: #899399;
    }
  }

  .active-case, .active-word {
    background-color: #dae4ed;
    color: #40b6e0;

    &:hover {
      background-color: #dfdfdf;
      color: #44b7e0;
    }
  }

  .result {
    font-size: 12px;
    font-family: "JetBrains Mono", sans-serif;
    margin: 0 20px;
    width: 70px;
    height: 28px;
    display: flex;
    justify-content: center;
    align-items: center;
  }

  .last, .next {
    width: 22px;
    height: 22px;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 18px;
    color: #6e6e6e;

    &:hover {
      background-color: #dfdfdf;
      border-radius: 5px;
      cursor: default;
    }
  }

  .close {
    margin-left: auto;
    margin-right: 5px;
    width: 22px;
    height: 22px;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 18px;
    color: #bec4c6;

    &:hover {
      background-color: #dfdfdf;
      border-radius: 5px;
      cursor: default;
    }
  }
}
</style>

运行演示

Java代码编辑
在这里插入图片描述

HTML代码编辑
在这里插入图片描述

源码下载

代码编辑组件

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值