@ -1,23 +1,56 @@
< template lang = "pug" >
< template >
. util - code - editor
<!--
textarea ( ref = 'cmRef' )
A real < textarea > with a highlighted copy of its own text painted underneath , rather than an
editor library . The textarea ' s text is transparent , so what you read is the < pre > and what you
type into is the textarea , and the two stay registered because they share every metric that
affects where a glyph lands ( see the style block ) .
Keeping the textarea is the point : native undo / redo , selection , spellcheck control , mobile
keyboards , form semantics and screen - reader behaviour all come for free , which neither a
contenteditable nor a canvas - drawn editor gives you .
-- >
< div
class = "util-code-editor"
: class = "{ 'util-code-editor--square': square }"
: style = "{ height: `${minHeight}px`, '--util-code-editor-gutter': gutterWidth }" >
< pre class = "util-code-editor-view" aria -hidden = " true " > < code v -html = " highlighted " / > < / pre >
< textarea
ref = "inputEl"
class = "util-code-editor-input"
: value = "modelValue"
: aria - label = "ariaLabel"
spellcheck = "false"
autocapitalize = "off"
autocomplete = "off"
autocorrect = "off"
@ input = "onInput"
@ scroll = "onScroll"
@ keydown . tab . exact . prevent = "onTab" / >
< / div >
< / template >
< / template >
< script setup >
< script setup >
/* eslint no-unused-vars: "off" */
/ * *
import { ref , shallowRef , onBeforeMount , onMounted , watch } from 'vue'
* Small code field for hand - entering CSS , HTML , JavaScript , JSON or YAML .
*
/ / C o d e M i r r o r
* Deliberately basic : no bracket matching , autocomplete , folding , multiple cursors or find . The
import CodeMirror from 'codemirror'
* places this appears are short config fields , and the editor it replaces cost 83 kB gzipped to
import 'codemirror/lib/codemirror.css'
* provide those .
*
/ / L a n g u a g e
* Not in ` components/shared/ ` , despite being a form control : that library is registered eagerly , so
import 'codemirror/mode/markdown/markdown.js'
* a ` w-* ` component here would pull the highlighter into the main bundle for every visitor . Imported
import 'codemirror/mode/htmlmixed/htmlmixed.js'
* per call site instead , it stays in the lazy chunks of the few screens that use it .
import 'codemirror/mode/css/css.js'
* /
import { computed , ref } from 'vue'
/ / A d d o n s
/ / - > ` l i b / c o r e ` p l u s n a m e d l a n g u a g e s , N O T t h e ` h i g h l i g h t . j s ` r o o t : t h a t r o o t r e g i s t e r s a l l ~ 1 9 0
import 'codemirror/addon/selection/active-line.js'
/ / l a n g u a g e s ( 5 6 3 k B g z i p p e d ) a n d i s w h y t h e p a g e r e n d e r e r ' s c h u n k i s t h e s i z e i t i s
import hljs from 'highlight.js/lib/core'
import css from 'highlight.js/lib/languages/css'
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import xml from 'highlight.js/lib/languages/xml'
import yaml from 'highlight.js/lib/languages/yaml'
/ / P R O P S
/ / P R O P S
@ -26,13 +59,31 @@ const props = defineProps({
type : String ,
type : String ,
default : ''
default : ''
} ,
} ,
/** `css` | `html` | `javascript` | `json` | `yaml`; anything else renders unhighlighted. */
language : {
language : {
type : String ,
type : String ,
default : 'plaintext'
default : 'plaintext'
} ,
} ,
/** Height of the field in px. Content beyond it scrolls. */
minHeight : {
minHeight : {
type : Number ,
type : Number ,
default : 150
default : 150
} ,
/** Accessible name, since a code field rarely has a visible <label>. */
ariaLabel : {
type : String ,
default : null
} ,
/ * *
* Sharp corners , for a field that spans its container edge to edge .
*
* Rounded corners read as a control inset in a surface ; where the editor IS the surface -- filling
* the width of a dialog between its toolbar and its buttons -- they cut across the dialog ' s own
* edge instead . Named as ` WChip ` and ` WAvatar ` name the same idea .
* /
square : {
type : Boolean ,
default : false
}
}
} )
} )
@ -42,93 +93,420 @@ const emit = defineEmits(['update:modelValue'])
/ / S T A T E
/ / S T A T E
const cm = shallowRef ( null )
const inputEl = ref ( null )
const cmRef = ref ( null )
/ / W A T C H E R S
/ *
Registered once for the module , not per instance : hljs keeps a single global registry , so repeating
this per component would just overwrite the same entries .
* /
hljs . registerLanguage ( 'css' , css )
hljs . registerLanguage ( 'javascript' , javascript )
hljs . registerLanguage ( 'json' , json )
hljs . registerLanguage ( 'xml' , xml )
hljs . registerLanguage ( 'yaml' , yaml )
watch (
/ *
( ) => props . modelValue ,
The ` language ` prop names the language the way the call sites already do ; ` html ` is hljs ' s ` xml ` .
( newVal ) => {
A name that is absent here ( ` plaintext ` , or anything unregistered ) is escaped and left alone rather
/ / I g n o r e l o o p b a c k c h a n g e s w h i l e e d i t i n g
than throwing , so a new call site cannot break the field by asking for a language nobody added .
if ( ! cm . value . hasFocus ( ) ) {
* /
cm . value . setValue ( newVal )
const HLJS _LANGUAGES = {
css : 'css' ,
html : 'xml' ,
javascript : 'javascript' ,
json : 'json' ,
yaml : 'yaml'
}
}
}
)
/ / M O U N T E D
const ESCAPES = { '&' : '&' , '<' : '<' , '>' : '>' }
onMounted ( async ( ) => {
/ *
let langMode = null
hljs emits nothing but ` <span class="…"> ` , ` </span> ` and escaped text , which is what makes this
switch ( props . language ) {
three - branch match sufficient .
case 'css' : {
* /
langMode = 'text/css'
const HLJS _TOKENS = /<span class="[^"]*">|<\/span>|[^<]+/g
break
}
/ * *
case 'html' : {
* Split highlighted HTML into one string per source line , keeping the spans balanced .
langMode = 'text/html'
*
break
* A token can legitimately run across a newline -- a block comment , a template literal , a YAML
}
* block scalar -- and its span then contains the break . Wrapping each line in an element of its own
case 'javascript' : {
* therefore cannot just cut the string at "\n" : the open spans have to be closed at the end of each
langMode = 'text/javascript'
* line and reopened at the start of the next , or the line elements nest inside one another and the
break
* numbering collapses to a single row .
}
* /
case 'json' : {
function splitHighlightedLines ( html ) {
langMode = {
const lines = [ ]
name : 'javascript' ,
/** The opening tags currently in effect, innermost last. */
json : true
const open = [ ]
let current = ''
for ( const [ token ] of html . matchAll ( HLJS _TOKENS ) ) {
if ( token === '</span>' ) {
open . pop ( )
current += token
} else if ( token . startsWith ( '<span' ) ) {
open . push ( token )
current += token
} else {
/ / - > T e x t i s t h e o n l y b r a n c h t h a t c a n h o l d a n e w l i n e
const parts = token . split ( '\n' )
for ( const [ index , part ] of parts . entries ( ) ) {
if ( index > 0 ) {
current += '</span>' . repeat ( open . length )
lines . push ( current )
current = open . join ( '' )
}
}
break
current += part
}
}
case 'markdown' : {
langMode = 'text/markdown'
break
}
}
default : {
langMode = null
break
}
}
lines . push ( current )
return lines
}
}
/ / - > I n i t i a l i z e C o d e M i r r o r
/ / C O M P U T E D
cm . value = CodeMirror . fromTextArea ( cmRef . value , {
tabSize : 2 ,
const lines = computed ( ( ) => {
mode : langMode ,
const value = props . modelValue ? ? ''
theme : 'wikijs-dark' ,
const language = HLJS _LANGUAGES [ props . language ]
lineNumbers : true ,
const html = language
lineWrapping : true ,
? / / - > P a r t i a l c o d e i s t h e n o r m a l s t a t e i n a f i e l d b e i n g t y p e d i n t o , s o i l l e g a l s y n t a x m u s t n o t
line : true ,
/ / a b o r t t h e h i g h l i g h t a n d b l a n k t h e v i e w
styleActiveLine : true ,
hljs . highlight ( value , { language , ignoreIllegals : true } ) . value
viewportMargin : 50 ,
: value . replace ( /[&<>]/g , ( c ) => ESCAPES [ c ] )
inputStyle : 'contenteditable' ,
direction : 'ltr'
} )
cm . value . setValue ( props . modelValue )
return splitHighlightedLines ( html )
cm . value . on ( 'change' , ( c ) => {
emit ( 'update:modelValue' , c . getValue ( ) )
} )
} )
cm . value . setSize ( null , ` ${ props . minHeight } px ` )
/ *
One block element per line , carrying its own number as a data attribute for the gutter to draw with
` content: attr() ` . Built as a single string rather than a v - for so the whole view is one innerHTML
write per keystroke instead of a element - by - element patch .
A trailing newline needs no special case here , unlike a plain < pre > : ` 'a \ n' ` splits into two lines ,
the second empty , which is exactly the line the caret is sitting on .
* /
const highlighted = computed ( ( ) =>
lines . value
. map (
( line , index ) => ` <span class="util-code-editor-line" data-line=" ${ index + 1 } "> ${ line } </span> `
)
. join ( '' )
)
/ / - > W i d e e n o u g h f o r t h e h i g h e s t l i n e n u m b e r t h e f i e l d c u r r e n t l y h o l d s . ` c h ` i s t h e w i d t h o f a d i g i t
/ / i n t h e g u t t e r ' s o w n f o n t , w h i c h i s w h y t h e c o n t a i n e r c a r r i e s t h e m o n o f o n t t o o
const gutterWidth = computed ( ( ) => ` calc( ${ String ( lines . value . length ) . length } ch + 1.35rem) ` )
/ / M E T H O D S
function onInput ( ev ) {
emit ( 'update:modelValue' , ev . target . value )
}
/ / - > T h e v i e w i s t h e e l e m e n t t h a t s c r o l l s o u t o f s i g h t , s o i t h a s t o f o l l o w t h e o n e w i t h t h e s c r o l l b a r
function onScroll ( ev ) {
const view = ev . target . previousElementSibling
view . scrollTop = ev . target . scrollTop
}
/ *
Tab indents by two , as the editor this replaces did .
Shift + Tab is deliberately NOT handled , so it still moves focus and a keyboard user is never trapped
in the field .
* /
function onTab ( ev ) {
const el = ev . target
const { selectionStart : start , selectionEnd : end , value } = el
emit ( 'update:modelValue' , ` ${ value . slice ( 0 , start ) } ${ value . slice ( end ) } ` )
/ / - > V u e w r i t e s t h e n e w v a l u e i n t o t h e e l e m e n t , w h i c h d r o p s t h e c a r e t a t t h e e n d u n l e s s i t i s p u t
/ / b a c k ; t h e p l a i n i n p u t p a t h n e v e r n e e d s t h i s b e c a u s e t h e D O M a l r e a d y h o l d s w h a t w a s e m i t t e d
requestAnimationFrame ( ( ) => {
el . selectionStart = start + 2
el . selectionEnd = start + 2
} )
} )
}
onBeforeMount ( ( ) => {
/ *
if ( cm . value ) {
Exposed so a host can put the caret in the field -- the scripts dialog focuses it on open . A method
cm . value . destroy ( )
rather than an ` autofocus ` prop , because focus is an action taken at a moment , not a state of the
component : a dialog that reopens with the same props has to be able to ask again .
* /
defineExpose ( {
focus ( ) {
inputEl . value ? . focus ( )
}
}
} )
} )
< / script >
< / script >
< style lang = "scss" >
< style lang = "scss" >
/ *
Unscoped , but every selector is under ` .util-code-editor ` . The highlighted markup arrives through
` v-html ` and so carries no scope attribute , which a scoped rule could only reach through ` :deep() `
on every line of the palette below .
* /
. util - code - editor {
. util - code - editor {
min - height : 100 px ;
position: relative ;
border : 1 px solid # ccc ;
overflow: hidden ;
border - radius : 5 px ;
border - radius : 5 px ;
/* -> Same resting and focus edge as the other form controls; see `.w-input-control` */
border : 1 px solid rgb ( 0 0 0 / 0.24 ) ;
background - color : # fff ;
transition : border - color 0.36 s cubic - bezier ( 0.4 , 0 , 0.2 , 1 ) ;
/ *
The text metrics live here as well as on the layers , for the ` ch ` in the gutter width : ` ch ` is
relative to the element ' s own font , so measured against the page font the gutter would be sized
for the wrong glyph .
* /
font - family : var ( -- font - mono ) ;
font - size : 13 px ;
/ *
The gutter ' s stripe , drawn on the container rather than inside the scrolling layer , so it stays
put while the numbers within it scroll -- which is what a gutter does .
* /
background - image : linear - gradient (
to right ,
# f6f8fa 0 ,
# f6f8fa var ( -- util - code - editor - gutter ) ,
rgb ( 0 0 0 / 0.09 ) var ( -- util - code - editor - gutter ) ,
rgb ( 0 0 0 / 0.09 ) calc ( var ( -- util - code - editor - gutter ) + 1 px ) ,
transparent calc ( var ( -- util - code - editor - gutter ) + 1 px )
) ;
& : focus - within {
border - color : var ( -- color - primary ) ;
}
}
/ *
- > A prop rather than a class the caller passes : the radius above is a single - class rule in an
unlayered stylesheet , so an override would come down to which file the bundler happened to emit
last . The component owning both states is the only version that cannot silently flip .
* /
. util - code - editor -- square {
border - radius : 0 ;
}
/ *
The two layers , and the reason this component works at all : any difference between them in a
property that affects glyph position -- font , size , line height , letter spacing , tab size , padding ,
wrapping -- shows up as the highlight drifting out from under the text , further with every line .
Change one , change both .
* /
. util - code - editor - view ,
. util - code - editor - input {
position : absolute ;
inset : 0 ;
margin : 0 ;
/* -> Text starts clear of the gutter on BOTH layers, or the two disagree by the gutter's width */
padding : 8 px 10 px 8 px calc ( var ( -- util - code - editor - gutter ) + 8 px ) ;
border : 0 ;
font - family : var ( -- font - mono ) ;
font - size : 13 px ;
line - height : 1.5 ;
tab - size : 2 ;
white - space : pre - wrap ;
overflow - wrap : break - word ;
}
. util - code - editor - view {
overflow : hidden ;
overflow : hidden ;
/* -> Clicks belong to the textarea underneath, including the click that places the caret */
pointer - events : none ;
color : # 24292 f ;
}
/ *
One block per source line , each drawing its own number .
The number is an absolutely positioned pseudo - element on the line rather than a row in a parallel
gutter list , which is what keeps it correct under soft wrapping : a line that wraps to three rows is
one block three rows tall , and its number sits at the top of that block -- beside the row the line
actually starts on . A parallel list would drift by one row per wrap .
Being a pseudo - element also means the numbers cannot be selected or copied : the layer is
` aria-hidden ` and unclickable , and the textarea underneath is what a selection actually addresses .
* /
. util - code - editor - line {
display : block ;
position : relative ;
> . CodeMirror {
/ * - > A n e m p t y b l o c k w o u l d b e z e r o r o w s h i g h , s o a b l a n k l i n e w o u l d c o l l a p s e a n d t a k e i t s n u m b e r
height : 150 px ;
with it . Matches the ` line-height ` above ; the two have to move together . * /
min - height : 1.5 em ;
& : : before {
content : attr ( data - line ) ;
position : absolute ;
/ *
Out into the stripe the container paints . 8 px of the offset only cancels the text ' s own left
padding , which lands the number hard against the divider ; the other 10 px is the gap that keeps
it off . No width or ` text-align ` needed -- an absolutely positioned box with ` right ` set and no
width shrinks to its content , so the digits right - align across lines by themselves , and because
the offset is constant while the gutter grows in ` ch ` , the gap holds at any number of digits .
* /
right : calc ( 100 % + 18 px ) ;
color : rgb ( 0 0 0 / 0.38 ) ;
}
}
. util - code - editor - input {
overflow : auto ;
resize : none ;
outline : none ;
background - color : transparent ;
/* -> The text is read off the layer below; only the caret and the selection band come from here */
color : transparent ;
caret - color : # 24292 f ;
& : : selection {
background - color : rgb ( 25 118 210 / 0.28 ) ;
}
}
/ *
Token palette . Two flat sets keyed off ` body--dark ` rather than a per - theme stylesheet : switching
appearance is then a class on < body > , which is what lets the three editors on admin / theme recolour
the instant the dark - mode toggle on that same page is thrown -- no fetch , no re - init , no JS .
* /
. util - code - editor {
. hljs - comment ,
. hljs - quote {
color : # 6 a737d ;
font - style : italic ;
}
. hljs - keyword ,
. hljs - selector - tag ,
. hljs - literal ,
. hljs - doctag ,
. hljs - formula {
color : # d73a49 ;
}
. hljs - string ,
. hljs - regexp ,
. hljs - addition ,
. hljs - selector - attr ,
. hljs - selector - pseudo {
color : # 032 f62 ;
}
. hljs - number ,
. hljs - variable ,
. hljs - template - variable ,
. hljs - symbol ,
. hljs - bullet ,
. hljs - attr ,
. hljs - meta {
color : # 005 cc5 ;
}
. hljs - title ,
. hljs - section ,
. hljs - selector - id ,
. hljs - selector - class {
color : # 6 f42c1 ;
}
. hljs - built _in ,
. hljs - type ,
. hljs - attribute ,
. hljs - property ,
. hljs - params {
color : # e36209 ;
}
. hljs - name ,
. hljs - tag {
color : # 22863 a ;
}
. hljs - deletion {
color : # b31d28 ;
}
. hljs - emphasis {
font - style : italic ;
}
. hljs - strong {
font - weight : 600 ;
}
}
body . body -- dark {
. util - code - editor {
border - color : rgb ( 255 255 255 / 0.3 ) ;
background - color : $dark - 5 ;
background - image : linear - gradient (
to right ,
$dark - 4 0 ,
$dark - 4 var ( -- util - code - editor - gutter ) ,
rgb ( 255 255 255 / 0.12 ) var ( -- util - code - editor - gutter ) ,
rgb ( 255 255 255 / 0.12 ) calc ( var ( -- util - code - editor - gutter ) + 1 px ) ,
transparent calc ( var ( -- util - code - editor - gutter ) + 1 px )
) ;
& : focus - within {
border - color : var ( -- color - primary ) ;
}
}
. util - code - editor - view {
color : # e6edf3 ;
}
. util - code - editor - line : : before {
color : rgb ( 255 255 255 / 0.34 ) ;
}
. util - code - editor - input {
caret - color : # e6edf3 ;
}
. util - code - editor {
. hljs - comment ,
. hljs - quote {
color : # 8 b949e ;
}
. hljs - keyword ,
. hljs - selector - tag ,
. hljs - literal ,
. hljs - doctag ,
. hljs - formula {
color : # ff7b72 ;
}
. hljs - string ,
. hljs - regexp ,
. hljs - addition ,
. hljs - selector - attr ,
. hljs - selector - pseudo {
color : # a5d6ff ;
}
. hljs - number ,
. hljs - variable ,
. hljs - template - variable ,
. hljs - symbol ,
. hljs - bullet ,
. hljs - attr ,
. hljs - meta {
color : # 79 c0ff ;
}
. hljs - title ,
. hljs - section ,
. hljs - selector - id ,
. hljs - selector - class {
color : # d2a8ff ;
}
. hljs - built _in ,
. hljs - type ,
. hljs - attribute ,
. hljs - property ,
. hljs - params {
color : # ffa657 ;
}
. hljs - name ,
. hljs - tag {
color : # 7 ee787 ;
}
. hljs - deletion {
color : # ffa198 ;
}
}
}
}
}
< / style >
< / style >