feat: code editor

This commit is contained in:
Jannat Patel
2024-04-19 14:59:40 +05:30
parent 44b36599c3
commit f15fdcc42e
6 changed files with 1114 additions and 1182 deletions

View File

@@ -103,7 +103,7 @@ const props = defineProps({
})
onMounted(() => {
if (!user.data?.is_moderator || !user.data?.is_instructor) {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
window.location.href = '/login'
}
editor.value = renderEditor('content')
@@ -447,18 +447,93 @@ const breadcrumbs = computed(() => {
max-width: none;
}
.ce-code__textarea {
min-height: 200px;
font-family: Menlo, Monaco, Consolas, Courier New, monospace;
color: #41314e;
line-height: 1.6em;
font-size: 12px;
background: #f8f7fa;
border: 1px solid #f1f1f4;
box-shadow: none;
white-space: pre;
word-wrap: normal;
overflow-x: auto;
resize: vertical;
.codeBoxHolder {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.codeBoxTextArea {
width: 100%;
min-height: 30px;
padding: 10px;
border-radius: 2px 2px 2px 0;
border: none !important;
outline: none !important;
font: 14px monospace;
}
.codeBoxSelectDiv {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
position: relative;
}
.codeBoxSelectInput {
border-radius: 0 0 20px 2px;
padding: 2px 26px;
padding-top: 0;
padding-right: 0;
text-align: left;
cursor: pointer;
border: none !important;
outline: none !important;
}
.codeBoxSelectDropIcon {
position: absolute !important;
left: 10px !important;
bottom: 0 !important;
width: unset !important;
height: unset !important;
font-size: 16px !important;
}
.codeBoxSelectPreview {
display: none;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
border-radius: 2px;
box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13);
position: absolute;
top: 100%;
margin: 5px 0;
max-height: 30vh;
overflow-x: hidden;
overflow-y: auto;
z-index: 10000;
}
.codeBoxSelectItem {
width: 100%;
padding: 5px 20px;
margin: 0;
cursor: pointer;
}
.codeBoxSelectItem:hover {
opacity: 0.7;
}
.codeBoxSelectedItem {
background-color: lightblue !important;
}
.codeBoxShow {
display: flex !important;
}
.dark {
color: #abb2bf;
background-color: #282c34;
}
.light {
color: #383a42;
background-color: #fafafa;
}
</style>

View File

@@ -391,18 +391,93 @@ const allowInstructorContent = () => {
max-width: unset;
}
.ce-code__textarea {
min-height: 200px;
font-family: Menlo, Monaco, Consolas, Courier New, monospace;
color: #41314e;
line-height: 1.6em;
font-size: 12px;
background: #f8f7fa;
border: 1px solid #f1f1f4;
box-shadow: none;
white-space: pre;
word-wrap: normal;
overflow-x: auto;
resize: vertical;
.codeBoxHolder {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.codeBoxTextArea {
width: 100%;
min-height: 30px;
padding: 10px;
border-radius: 2px 2px 2px 0;
border: none !important;
outline: none !important;
font: 14px monospace;
}
.codeBoxSelectDiv {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
position: relative;
}
.codeBoxSelectInput {
border-radius: 0 0 20px 2px;
padding: 2px 26px;
padding-top: 0;
padding-right: 0;
text-align: left;
cursor: pointer;
border: none !important;
outline: none !important;
}
.codeBoxSelectDropIcon {
position: absolute !important;
left: 10px !important;
bottom: 0 !important;
width: unset !important;
height: unset !important;
font-size: 16px !important;
}
.codeBoxSelectPreview {
display: none;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
border-radius: 2px;
box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13);
position: absolute;
top: 100%;
margin: 5px 0;
max-height: 30vh;
overflow-x: hidden;
overflow-y: auto;
z-index: 10000;
}
.codeBoxSelectItem {
width: 100%;
padding: 5px 20px;
margin: 0;
cursor: pointer;
}
.codeBoxSelectItem:hover {
opacity: 0.7;
}
.codeBoxSelectedItem {
background-color: lightblue !important;
}
.codeBoxShow {
display: flex !important;
}
.dark {
color: #abb2bf;
background-color: #282c34;
}
.light {
color: #383a42;
background-color: #fafafa;
}
</style>

View File

@@ -1,233 +0,0 @@
import { getLineStartPosition } from './index.js'
export class CodeTool {
static get isReadOnlySupported() {
return true
}
static get enableLineBreaks() {
return true
}
constructor({ data, config, api, readOnly }) {
this.api = api
this.readOnly = readOnly
this.placeholder = this.api.i18n.t(
config.placeholder || CodeTool.DEFAULT_PLACEHOLDER
)
this.CSS = {
baseClass: this.api.styles.block,
input: this.api.styles.input,
wrapper: 'ce-code',
textarea: 'ce-code__textarea',
select: 'ce-code__languagecode',
}
if (config.languageList) {
this.languageList = config.languageList
} else {
this.languageList = [
{ name: 'Select Language', code: '' },
{ name: 'HTML', code: 'html' },
{ name: 'CSS', code: 'css' },
{ name: 'JavaScript', code: 'js' },
{ name: 'C#', code: 'csharp' },
{ name: 'ASP.NET (C#)', code: 'aspnet' },
{ name: 'Docker', code: 'docker' },
{ name: 'Git', code: 'git' },
{ name: 'Java', code: 'java' },
{ name: 'JSON', code: 'json' },
{ name: 'JSONP', code: 'jsonp' },
{ name: 'JSON5', code: 'json5' },
{ name: 'PowerShell', code: 'powershell' },
{ name: 'Python', code: 'python' },
{ name: 'React JSX', code: 'jsx' },
{ name: 'React TSX', code: 'tsx' },
{ name: 'Regex', code: 'regex' },
{ name: 'Sass (Sass)', code: 'sass' },
{ name: 'Sass (Scss)', code: 'scss' },
{ name: 'SQL', code: 'sql' },
{ name: 'TypeScript', code: 'typescript' },
{ name: 'YAML', code: 'yaml' },
]
}
if (config.additionalLanguages) {
this.languageList.push(...config.additionalLanguages)
}
this.nodes = {
holder: null,
textarea: null,
picker: null,
}
this.data = {
code: data.code || '',
languageCode: data.languageCode?.replace('language-', '') || '',
}
this.nodes.holder = this.drawView()
}
drawView() {
const wrapper = document.createElement('div'),
textarea = document.createElement('textarea'),
picker = document.createElement('select')
this.languageList.forEach((item) => {
let option = document.createElement('option')
option.text = item.name
option.value = item.code
picker.appendChild(option)
})
wrapper.classList.add(this.CSS.baseClass, this.CSS.wrapper)
textarea.classList.add(this.CSS.textarea, this.CSS.input)
textarea.textContent = this.data.code
picker.value = this.data.languageCode
textarea.placeholder = this.placeholder
if (this.readOnly) {
textarea.disabled = true
picker.disabled = true
}
wrapper.appendChild(picker)
wrapper.appendChild(textarea)
textarea.addEventListener('keydown', (event) => {
switch (event.code) {
case 'Tab':
this.tabHandler(event)
break
}
})
this.nodes.textarea = textarea
this.nodes.picker = picker
return wrapper
}
render() {
return this.nodes.holder
}
save(codeWrapper) {
return {
code: codeWrapper.querySelector('textarea').value,
languageCode: `language-${
codeWrapper.querySelector('select').value
}`,
}
}
onPaste(event) {
const content = event.detail.data
this.data = {
code: content.textContent,
}
}
get data() {
return this._data
}
set data(data) {
this._data = data
if (this.nodes.textarea) {
this.nodes.textarea.textContent = data.code
}
if (this.nodes.picker) {
this.nodes.picker.value = data.languageCode
}
}
static get toolbox() {
return {
icon: '<svg width="14" height="14" viewBox="0 -1 14 14" xmlns="http://www.w3.org/2000/svg" > <path d="M3.177 6.852c.205.253.347.572.427.954.078.372.117.844.117 1.417 0 .418.01.725.03.92.02.18.057.314.107.396.046.075.093.117.14.134.075.027.218.056.42.083a.855.855 0 0 1 .56.297c.145.167.215.38.215.636 0 .612-.432.934-1.216.934-.457 0-.87-.087-1.233-.262a1.995 1.995 0 0 1-.853-.751 2.09 2.09 0 0 1-.305-1.097c-.014-.648-.029-1.168-.043-1.56-.013-.383-.034-.631-.06-.733-.064-.263-.158-.455-.276-.578a2.163 2.163 0 0 0-.505-.376c-.238-.134-.41-.256-.519-.371C.058 6.76 0 6.567 0 6.315c0-.37.166-.657.493-.846.329-.186.56-.342.693-.466a.942.942 0 0 0 .26-.447c.056-.2.088-.42.097-.658.01-.25.024-.85.043-1.802.015-.629.239-1.14.672-1.522C2.691.19 3.268 0 3.977 0c.783 0 1.216.317 1.216.921 0 .264-.069.48-.211.643a.858.858 0 0 1-.563.29c-.249.03-.417.076-.498.126-.062.04-.112.134-.139.291-.031.187-.052.562-.061 1.119a8.828 8.828 0 0 1-.112 1.378 2.24 2.24 0 0 1-.404.963c-.159.212-.373.406-.64.583.25.163.454.342.612.538zm7.34 0c.157-.196.362-.375.612-.538a2.544 2.544 0 0 1-.641-.583 2.24 2.24 0 0 1-.404-.963 8.828 8.828 0 0 1-.112-1.378c-.009-.557-.03-.932-.061-1.119-.027-.157-.077-.251-.14-.29-.08-.051-.248-.096-.496-.127a.858.858 0 0 1-.564-.29C8.57 1.401 8.5 1.185 8.5.921 8.5.317 8.933 0 9.716 0c.71 0 1.286.19 1.72.574.432.382.656.893.671 1.522.02.952.033 1.553.043 1.802.009.238.041.458.097.658a.942.942 0 0 0 .26.447c.133.124.364.28.693.466a.926.926 0 0 1 .493.846c0 .252-.058.446-.183.58-.109.115-.281.237-.52.371-.21.118-.377.244-.504.376-.118.123-.212.315-.277.578-.025.102-.045.35-.06.733-.013.392-.027.912-.042 1.56a2.09 2.09 0 0 1-.305 1.097c-.2.323-.486.574-.853.75a2.811 2.811 0 0 1-1.233.263c-.784 0-1.216-.322-1.216-.934 0-.256.07-.47.214-.636a.855.855 0 0 1 .562-.297c.201-.027.344-.056.418-.083.048-.017.096-.06.14-.134a.996.996 0 0 0 .107-.396c.02-.195.031-.502.031-.92 0-.573.039-1.045.117-1.417.08-.382.222-.701.427-.954z" /> </svg>',
title: 'Code',
}
}
static get DEFAULT_PLACEHOLDER() {
return 'Enter a code'
}
static get pasteConfig() {
return {
tags: ['pre'],
}
}
static get sanitize() {
return {
code: true, // Allow HTML tags
}
}
tabHandler(event) {
/**
* Prevent editor.js tab handler
*/
event.stopPropagation()
/**
* Prevent native tab behaviour
*/
event.preventDefault()
const textarea = event.target
const isShiftPressed = event.shiftKey
const caretPosition = textarea.selectionStart
const value = textarea.value
const indentation = ' '
let newCaretPosition
/**
* For Tab pressing, just add an indentation to the caret position
*/
if (!isShiftPressed) {
newCaretPosition = caretPosition + indentation.length
textarea.value =
value.substring(0, caretPosition) +
indentation +
value.substring(caretPosition)
} else {
/**
* For Shift+Tab pressing, remove an indentation from the start of line
*/
const currentLineStart = getLineStartPosition(value, caretPosition)
const firstLineChars = value.substr(
currentLineStart,
indentation.length
)
if (firstLineChars !== indentation) {
return
}
/**
* Trim the first two chars from the start of line
*/
textarea.value =
value.substring(0, currentLineStart) +
value.substring(currentLineStart + indentation.length)
newCaretPosition = caretPosition - indentation.length
}
/**
* Restore the caret
*/
textarea.setSelectionRange(newCaretPosition, newCaretPosition)
}
}

208
frontend/src/utils/code.ts Normal file
View File

@@ -0,0 +1,208 @@
const DEFAULT_THEMES = ['light', 'dark'];
const COMMON_LANGUAGES = {
none: 'Auto-detect', apache: 'Apache', bash: 'Bash', cs: 'C#', cpp: 'C++', css: 'CSS', coffeescript: 'CoffeeScript', diff: 'Diff',
go: 'Go', html: 'HTML, XML', http: 'HTTP', json: 'JSON', java: 'Java', javascript: 'JavaScript', kotlin: 'Kotlin',
less: 'Less', lua: 'Lua', makefile: 'Makefile', markdown: 'Markdown', nginx: 'Nginx', objectivec: 'Objective-C',
php: 'PHP', perl: 'Perl', properties: 'Properties', python: 'Python', ruby: 'Ruby', rust: 'Rust', scss: 'SCSS',
sql: 'SQL', shell: 'Shell Session', swift: 'Swift', toml: 'TOML, also INI', typescript: 'TypeScript', yaml: 'YAML',
plaintext: 'Plaintext'
};
export class CodeBox {
api: any;
config: { themeName: any; themeURL: any; useDefaultTheme: any; };
readOnly: boolean;
data: { code: any; language: any; theme: any; };
highlightScriptID: string;
highlightCSSID: string;
codeArea: HTMLDivElement;
selectInput: HTMLInputElement;
selectDropIcon: HTMLElement;
constructor({ data, api, config, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.config = {
themeName: config.themeName && typeof config.themeName === 'string' ? config.themeName : '',
themeURL: config.themeURL && typeof config.themeURL === 'string' ? config.themeURL : '',
useDefaultTheme: (config.useDefaultTheme && typeof config.useDefaultTheme === 'string'
&& DEFAULT_THEMES.includes(config.useDefaultTheme.toLowerCase())) ? config.useDefaultTheme : 'dark',
};
this.data = {
code: data.code && typeof data.code === 'string' ? data.code : '',
language: data.language && typeof data.language === 'string' ? data.language : 'Auto-detect',
theme: data.theme && typeof data.theme === 'string' ? data.theme : this._getThemeURLFromConfig(),
};
this.highlightScriptID = 'highlightJSScriptElement';
this.highlightCSSID = 'highlightJSCSSElement';
this.codeArea = document.createElement('div');
this.selectInput = document.createElement('input');
this.selectDropIcon = document.createElement('i');
this._injectHighlightJSScriptElement();
this._injectHighlightJSCSSElement();
this.api.listeners.on(window, 'click', this._closeAllLanguageSelects, true);
}
static get isReadOnlySupported() {
return true
}
static get sanitize() {
return {
code: true,
language: false,
theme: false,
}
}
static get toolbox() {
return {
title: 'CodeBox',
icon: '<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.71,6.29a1,1,0,0,0-1.42,0l-5,5a1,1,0,0,0,0,1.42l5,5a1,1,0,0,0,1.42,0,1,1,0,0,0,0-1.42L5.41,12l4.3-4.29A1,1,0,0,0,9.71,6.29Zm11,5-5-5a1,1,0,0,0-1.42,1.42L18.59,12l-4.3,4.29a1,1,0,0,0,0,1.42,1,1,0,0,0,1.42,0l5-5A1,1,0,0,0,20.71,11.29Z"/></svg>'
};
}
static get displayInToolbox() {
return true;
}
static get enableLineBreaks() {
return true;
}
render() {
const codeAreaHolder = document.createElement('pre');
const languageSelect = this._createLanguageSelectElement();
codeAreaHolder.setAttribute('class', 'codeBoxHolder');
this.codeArea.setAttribute('class', `codeBoxTextArea ${this.config.useDefaultTheme} ${this.data.language}`);
this.codeArea.setAttribute('contenteditable', 'true');
this.codeArea.innerHTML = this.data.code;
this.api.listeners.on(this.codeArea, 'blur', event => this._highlightCodeArea(event), false);
this.api.listeners.on(this.codeArea, 'paste', event => this._handleCodeAreaPaste(event), false);
codeAreaHolder.appendChild(this.codeArea);
!this.readOnly && codeAreaHolder.appendChild(languageSelect);
return codeAreaHolder;
}
save(blockContent) {
return Object.assign(this.data, { code: this.codeArea.innerHTML, theme: this._getThemeURLFromConfig() });
}
validate(savedData) {
if (!savedData.code.trim()) return false;
return true;
}
destroy() {
this.api.listeners.off(window, 'click', this._closeAllLanguageSelects, true);
this.api.listeners.off(this.codeArea, 'blur', event => this._highlightCodeArea(event), false);
this.api.listeners.off(this.codeArea, 'paste', event => this._handleCodeAreaPaste(event), false);
this.api.listeners.off(this.selectInput, 'click', event => this._handleSelectInputClick(event), false);
}
_createLanguageSelectElement() {
const selectHolder = document.createElement('div');
const selectPreview = document.createElement('div');
const languages = Object.entries(COMMON_LANGUAGES);
selectHolder.setAttribute('class', 'codeBoxSelectDiv');
this.selectDropIcon.setAttribute('class', `codeBoxSelectDropIcon ${this.config.useDefaultTheme}`);
this.selectDropIcon.innerHTML = '&#8595;';
this.selectInput.setAttribute('class', `codeBoxSelectInput ${this.config.useDefaultTheme}`);
this.selectInput.setAttribute('type', 'text');
this.selectInput.setAttribute('readonly', 'true');
this.selectInput.value = this.data.language;
this.api.listeners.on(this.selectInput, 'click', event => this._handleSelectInputClick(event), false);
selectPreview.setAttribute('class', 'codeBoxSelectPreview');
languages.forEach(language => {
const selectItem = document.createElement('p');
selectItem.setAttribute('class', `codeBoxSelectItem ${this.config.useDefaultTheme}`);
selectItem.setAttribute('data-key', language[0]);
selectItem.textContent = language[1];
this.api.listeners.on(selectItem, 'click', event => this._handleSelectItemClick(event, language), false);
selectPreview.appendChild(selectItem);
});
selectHolder.appendChild(this.selectDropIcon);
selectHolder.appendChild(this.selectInput);
selectHolder.appendChild(selectPreview);
return selectHolder;
}
_highlightCodeArea(event) {
window.hljs.highlightBlock(this.codeArea);
}
_handleCodeAreaPaste(event) {
event.stopPropagation();
}
_handleSelectInputClick(event) {
event.target.nextSibling.classList.toggle('codeBoxShow');
}
_handleSelectItemClick(event, language) {
event.target.parentNode.parentNode.querySelector('.codeBoxSelectInput').value = language[1];
event.target.parentNode.classList.remove('codeBoxShow');
this.codeArea.removeAttribute('class');
this.data.language = language[0];
this.codeArea.setAttribute('class', `codeBoxTextArea ${this.config.useDefaultTheme} ${this.data.language}`);
window.hljs.highlightBlock(this.codeArea);
}
_closeAllLanguageSelects() {
const selectPreviews = document.querySelectorAll('.codeBoxSelectPreview');
for (let i = 0, len = selectPreviews.length; i < len; i++) selectPreviews[i].classList.remove('codeBoxShow');
}
_injectHighlightJSScriptElement() {
const highlightJSScriptElement = document.querySelector(`#${this.highlightScriptID}`);
const highlightJSScriptURL = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/highlight.min.js';
if (!highlightJSScriptElement) {
const script = document.createElement('script');
const head = document.querySelector('head');
script.setAttribute('src', highlightJSScriptURL);
script.setAttribute('id', this.highlightScriptID);
if (head) head.appendChild(script);
}
else highlightJSScriptElement.setAttribute('src', highlightJSScriptURL);
}
_injectHighlightJSCSSElement() {
const highlightJSCSSElement = document.querySelector(`#${this.highlightCSSID}`);
let highlightJSCSSURL = this._getThemeURLFromConfig();
if (!highlightJSCSSElement) {
const link = document.createElement('link');
const head = document.querySelector('head');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('href', highlightJSCSSURL);
link.setAttribute('id', this.highlightCSSID);
if (head) head.appendChild(link);
}
else highlightJSCSSElement.setAttribute('href', highlightJSCSSURL);
}
_getThemeURLFromConfig() {
let themeURL = `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/atom-one-${this.config.useDefaultTheme}.min.css`;
if (this.config.themeName) themeURL = `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/${this.config.themeName}.min.css`;
if (this.config.themeURL) themeURL = this.config.themeURL;
return themeURL;
}
}
export default CodeBox;

View File

@@ -6,6 +6,7 @@ import { Upload } from '@/utils/upload'
import Header from '@editorjs/header'
import Paragraph from '@editorjs/paragraph'
import Embed from '@editorjs/embed'
import { CodeBox } from '@/utils/code'
import NestedList from '@editorjs/nested-list'
import InlineCode from '@editorjs/inline-code'
import { watch } from 'vue'
@@ -131,6 +132,15 @@ export function getEditorTools() {
class: Paragraph,
inlineToolbar: true,
},
codeBox: {
class: CodeBox,
config: {
themeURL:
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/dracula.min.css', // Optional
themeName: 'atom-one-dark', // Optional
useDefaultTheme: 'dark', // Optional. This also determines the background color of the language select drop-down
},
},
list: {
class: NestedList,
config: {