start . me .
Directory path . web .

HTML Document File : text-editable-canvas.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>text editor via canvas</title>
</head>
<body style="background-color: lightgrey;">

<pre id="page"></pre>
<button onclick="sync()">sync page pre-view</button>
<input type="checkbox" id="autoSync" checked> autoSync
    
    <h1>text editing via &lt;canvas> tag's 2D Graphic Output + Mouse/Touch Input</h1>
    <!-- <pre id=writeOut></pre> <script>function write(text){writeOut.textContent += text + "\n"}</script> -->
    
    <hr>
    wrap lines limit: <input type="range" id="number" value="0" max="40" oninput="reDraw() /* needs re-draw */">
    <hr>
    text-size: <input type="range" value="30" min="0" max="70" oninput="$font.height=parseInt(this.value)"> <br>
    <!-- text-size changes by <input type="range"> with proxify() use that works for more refined data-relations and data-updates handling (reusable-pattern) -->
    <canvas id="canvas1" width="10" height="10" style="border: 1px solid black; width: calc(100% - 2px); "></canvas> <br>

    <!--
    <button onclick="$text=prompt(); draw($text, $font);">set text</button>

    <button onclick="cursorMove(-1)"><<<<<<</button> <button onclick="cursorMove(+1)">>>>>>></button> <br>

    <button onclick="textDelete()">textDelete()</button>
    <button onclick="textInsert(prompt())">textInsert()</button> <br>

    <style>button { height: 4em; } </style>

    key code alphabet input with case, symbols and backspace, space, enter keys

    <pre id=textOut style="border: 1px solid red" ></pre> <br>
    -->

    <script>

    document.addEventListener('keypress', function(event) {

        // conversion
        var text = String.fromCharCode(event.keyCode)

        if(event.key == "Enter"){
            // new-line
            text = "\n"
        }

        if(event.key == " "){
            // space
            text = " "

            // space key scrolls page down by default behavior
            event.preventDefault()
        }

        insert(text)

    })

    document.addEventListener('keydown', function(event) {
        if(event.key=='Backspace') backspace()

        if(event.key=='ArrowRight') cursorMove(+1)
        if(event.key=='ArrowLeft') cursorMove(-1)

        if( event.key == "ArrowRight" || event.key == "ArrowLeft" ||
        event.key == "ArrowDown" || event.key == "ArrowUp"         
        ){
            // arrow keys scrolls page by default behavior
            event.preventDefault()
        }

        if(event.key=='ArrowDown') cursorMoveLine(+1)
        if(event.key=='ArrowUp') cursorMoveLine(-1)
    })

    function insert(text){
        if(window.upperCase) text = text.toUpperCase()

        // add at the end (no cursor)
        ///textOut.textContent += text

        textInsert(text)
    }

    // backspace key deletes backwards
    function backspace(){
        // remove from the end (no cursor)
        ///textOut.textContent = textOut.textContent.slice(0,-1)

        textDelete()
    }

    </script>

    <p><i>Touch-Screen KeyBoard</i> <b>input</b> is possible, also it's possible <i>Physical KeyBoard</i> e.g. using my Laptop PC (a <i>Tablet's external Physical KeyBoard</i> is also possible?)<br>
    <style>#keyButtons button{ width:60px;height:50px;  font-size:17pt; }</style>
    <span id=keyButtons></span>
    </p>

    <script>
    var keyStringEmbedded = "abcdefghijklmnopqrstuvwxyz0123456789+-*/^%,.?!:;_\"`'()[]{}<>=&\\|/@#$~" // '\' and '"' escaped
    var keyListURL = "key-list.txt" // '/web/key-list.txt'
    var online = false
    if(online) fetch(keyListURL).then(res=>res.text()).then(keyString=>setup(keyString)).catch(error=>setup(keyStringEmbedded))
    else setup(keyStringEmbedded)

    function setup(keyString)
    {
    window.newLine="\n"
    keyButtons.innerHTML += `<button onclick="insert(newLine)">line</button>`
    keyButtons.innerHTML += '<button onclick="backspace()">del</button>'
    window.upperCase=false; window.caseToggle = function(){ window.upperCase = ! window.upperCase }
    keyButtons.innerHTML += '<button onclick="caseToggle()">case</button>'
    keyButtons.innerHTML += `<button onclick="insert(' ')">&nbsp;</button>`

    keyButtons.innerHTML += '<button onclick="cursorMove(-1)">⇦</button>'
    keyButtons.innerHTML += '<button onclick="cursorMove(+1)">⇨</button>'

    ///var keyList="abcdefghijklmnopqrstuvwxyz0123456789+-*/^%,.?!:;_\"`'()[]{}<>=&\|/@#$~".split("")
    var keyList = keyString.split("")
    for(var keyChar of keyList)
        keyButtons.innerHTML += "<button onclick='insert(event.target.textContent)'>"+keyChar+"</button>"
    }
    </script>

    <!--*************************************-->

    <script>
    <!-- text-size changes by <input type="range"> with proxify() use that works for more refined data-relations and data-updates handling (reusable-pattern) -->
    function proxify(objectToProxy) {
        var proxy
        var handler = {

            // handle access
            get(target, accessedPropertyName, receiver) {
                return typeof target[accessedPropertyName] === 'function' ?
                    // OPTIONALly callable
                    target[accessedPropertyName] ( proxy /* same */ )
                    : target[accessedPropertyName]
            },

            // handle assignment like = or +=
            set(target, accessedPropertyName, value) {
                var postfix="_Set"
                if(typeof target[accessedPropertyName+postfix] != "undefined"){
                    // OPTIONAL SETter
                    target[accessedPropertyName+postfix](proxy /* same */ ) (value)
                    return true
                }
                target[accessedPropertyName] = value
                return true
            },
        }
        // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
        proxy = new Proxy(objectToProxy, handler)
        return proxy
    }
    </script>

    <script>
    var canvas = document.getElementById('canvas1')
    var context = canvas.getContext('2d')
    function measureTextWidth(text){ // needs "context"
        var measure = Math.ceil( context.measureText(text).width )
        return measure
    }

    <!-- text-size changes by <input type="range"> with proxify() use that works for more refined data-relations and data-updates handling (reusable-pattern) -->
    /*
    // was:
    var $font = {}
    $font.string = '30px monospace'
    $font.height = 30
    */
    var $font = proxify({})
    $font.height = _=> _.height_Value
    $font.height_Value = 30 // on-set : reDraw()
    $font.height_Set = _=> number => { _.height_Value = number ; reDraw() }
    $font.string = _=> _.height + 'px monospace' // '30px monospace'

    $cursorPosition = 0

    screenCountRows = 0 // previous value // was : rowsCount
    screenCountRowsPrevious = screenCountRows

    var $text = "" // "<b>multi-line text</b>\nline2\nline3.............."
    draw()

    var needsRedraw = true
    var previousClientWidth = canvas.clientWidth
    var previousClientHeight = canvas.clientHeight
    setInterval(redrawChecks)
    function redrawChecks(){
        ///draw() // always, disable this for conditional re-draw

        if(previousClientWidth != canvas.clientWidth){ // screen size changes
            previousClientWidth = canvas.clientWidth
            reDraw()
        }
        if(previousClientHeight != canvas.clientHeight){ // screen size changes
            previousClientHeight = canvas.clientHeight
            reDraw()
        }

        if(needsRedraw){
            draw() // conditional redraw
            needsRedraw = false
        }
    }

    function reDraw(){
        needsRedraw = true
    }

    function draw(){
        
        //console.log("draw()")

        var text = $text, font = $font

        if( $text[$text.length-1] != "\n" ) $text += "\n"

        var x = 0, y = 0
        
        // deltas
        context.font = font.string
        dx = measureTextWidth(" ")
        dy = font.height

        // lines
        var lines = text.split("\n")
        if(lines[lines.length-1]=="") lines.length -= 1

        // maxLength
        maxLength = 0 ; for(var line of lines) maxLength = Math.max(maxLength, line.length)
        maxLength += 1 // extra length

        var wordWrapMaxIndex
        if(typeof number != "undefined") wordWrapMaxIndex = parseInt ( number.value ) // 8
        if(typeof wordWrapMaxIndex != "number") wordWrapMaxIndex = 0
        var wordWrapEnabled = wordWrapMaxIndex != 0

        var columnsCountMaxForScreen = Math.floor( canvas.clientWidth / dx ) // canvas.clientWidth is derived from screen-orientation also
        
        var columnsCount = /*Math.max(maxLength,*/ columnsCountMaxForScreen // needs redraw when canvas.clientWidth changes

        if(wordWrapEnabled && wordWrapMaxIndex===undefined) wordWrapMaxIndex = columnsCount-1

        // ranges in lines (string, not screen. see word-wrapping)
        stringIdRanges = [] // string-lines
        var charIndex1 = 0
        ///var lineIndex = -1
        for(var line of lines){
            ///lineIndex += 1
            var charIndex2 = charIndex1 + line.length
            stringIdRanges.push([charIndex1, charIndex2, line.length+1])
            charIndex1 = charIndex2 + 1
        }

        // size the canvas
        canvas.width = dx * columnsCount
        canvas.height = dy * (screenCountRows + 0.5)

        // setup again after canvas size changes
        context = canvas.getContext('2d')
        context.font = font.string

        var xi = 0 , yi = 0
        indicesToPositionMapping = {}
        var stringLineId = 0 // real line, in string
        var screenLineId = 0 // screen lines, as displayed
        
        screenIdRanges = [] // for screen-lines, besides string-lines above
        var screenRangeIndexBegin = 0 , screenRangeIndexEnd

        stringLineLenghts = []
        var stringLineLength = 0
        screenLineLenghts = []
        var screenLineLenght = 0

        for( var characterId=0; characterId < text.length; characterId++ )
        {
            var character = text[characterId]
            
            var wordWrap
            indicesToPositionMapping[xi+","+yi] = characterId

            {
                context.fillText(character, x, y +dy ) // draw character
                if( characterId == $cursorPosition ){ // at cursor position
                    context.strokeRect(x, y, dx, dy) // draw cursor
                    cursorX = xi ; cursorY = yi
                    
                    stringLineIdOfCursor = stringLineId
                    screenLineIdOfCursor = screenLineId

                    cursorMoveLine = function cursorMoveLine(deltaY){
                        var calculatedCursorPosition = indicesToPositionMapping[cursorX+","+(cursorY+deltaY)]
                        
                        if(calculatedCursorPosition !== undefined){
                            $cursorPosition = calculatedCursorPosition
                        }
                        else
                        
                        {
                            var deltaIdRange = screenIdRanges[ screenLineIdOfCursor + deltaY]
                            if(deltaIdRange !== undefined){
                                $cursorPosition = deltaIdRange[1]
                            }
                        }
                        // needs redraw for changed $cursorPosition
                        reDraw() // needs re-draw
                    }
                }
                x += dx // next in line

                wordWrap = wordWrapEnabled && wordWrapMaxIndex == xi
                xi += 1
            }
            if(character=="\n" || wordWrap ) // new line or word-wrap
            { x = 0 ; y += dy ; xi = 0 ; yi += 1 }

            //**************************************

            if(character=="\n") stringLineId += 1 // "real line" (in string)
            if(character=="\n" || wordWrap ) screenLineId += 1 // screen-displayed line

            //**************************************
            stringLineLength += 1
            if(character=="\n"){ // new line only
                stringLineLenghts.push(stringLineLength)
                stringLineLength = 0
            }

            screenLineLenght += 1
            if(character=="\n" || wordWrap ){ // new line or word-wrap

                screenRangeIndexEnd = screenRangeIndexBegin + screenLineLenght-1
                screenIdRanges.push([ screenRangeIndexBegin , screenRangeIndexEnd , screenLineLenght ])
                screenRangeIndexBegin = screenRangeIndexEnd + 1

                //======================
                
                screenLineLenghts.push(screenLineLenght)
                screenLineLenght = 0

            }
            
        }

        screenCountRows = screenIdRanges.length
        
        if(screenCountRowsPrevious != screenCountRows){
            screenCountRowsPrevious = screenCountRows
            draw() // draw() to cycle, not just reDraw()
        }

    }

    function cursorMove(delta){ // -1 or +1
        setCursorPosition($cursorPosition + delta)
    }

    function bounds(){
        $cursorPosition = Math.max(0, Math.min( $text.length-1 , $cursorPosition ) ) // bounds
    }

    function setCursorPosition(cursorPosition){
        $cursorPosition = cursorPosition
        bounds()
        reDraw() // needs re-draw
    }

    function textDelete(){
        bounds()
        $text = $text.substr(0, $cursorPosition - 1) + $text.substr($cursorPosition, $text.length)
        $cursorPosition -= 1
        bounds()
        reDraw() // needs re-draw
    }

    function textInsert(textToInsert){
        bounds()
        $text = $text.substring(0, $cursorPosition) + textToInsert + $text.substring($cursorPosition)
        $cursorPosition += textToInsert.length
        bounds()
        reDraw() // needs re-draw
    }

    canvas1.onclick = function(event){
        var idRanges = screenIdRanges

        var point = {x: event.offsetX, y: event.offsetY}
        lineNumber = Math.floor(point.y / dy)
        columnNumber = Math.floor(point.x / dx)
        bounds()

        lineNumber = Math.min(lineNumber, idRanges.length-1)
        columnNumber = Math.min(columnNumber, idRanges[lineNumber][1] - idRanges[lineNumber][0])
        $cursorPosition = idRanges[lineNumber][0] + columnNumber

        bounds()
        reDraw() // needs re-draw
    }

    var initialText = "<b>multi-line text</b>\nline2\nline3.............."
    $text = initialText
    draw()

    ///textOut.textContent = $text

//===============================================

    // from var (variable in localStorage)
    $text = localStorage.html || $text ; reDraw() // editor <- var
    setHTML($text) // page <- var
    
    // from editor
    function sync(){
        localStorage.html = $text // var <- editor
        setHTML($text) // page <- editor
    }
    
    function setHTML(html){
        //page.innerHTML = html // this doesn't properly handle e.g. <script> tags
        nodeContentFromCode(html, page) // this does handle <script> tags etc.
    }
    
    setInterval( function(){
        if( autoSync.checked ) sync()
    }, 1500)
    
    function nodeContentFromCode(code, node) {
        var range = document.createRange()
        range.selectNode(document.body)
        var documentFragment = range.createContextualFragment(code)
        node.innerHTML = ""
        node.appendChild(documentFragment)
    }
    </script>

    <!-- /web/show-source.js -->
    <script src="show-source.js"></script>

</body>
</html>