├── demo ├── make.sh ├── README.md ├── package-lock.json ├── public │ ├── assets │ │ ├── math-text.js │ │ ├── math-text-delayed.js │ │ ├── outside.js │ │ ├── custom-element-config.js │ │ └── style.css │ └── index.html ├── package.json ├── elm-dev.json ├── elm.json ├── elm-pub.json └── src │ ├── Outside.elm │ ├── AppText.elm │ └── Demo.elm ├── docs ├── DEVTRICKS.md ├── README_SYDNEY_NEMZER.md └── Embedding.md ├── testSpares ├── elm-verify-examples.json ├── Tests │ ├── NoOp.elm │ ├── Insert.elm │ ├── CursorLeft.elm │ ├── Invariants.elm │ ├── CursorRight.elm │ └── Common.elm ├── EditorTest.elm └── UnitTest.elm ├── demo-simple ├── README.md ├── package-lock.json ├── public │ └── index.html ├── scripts │ └── publish.sh ├── package.json ├── elm-dev.json ├── elm.json ├── elm-pub.json └── src │ ├── DemoSimple.elm │ └── AppText.elm ├── src ├── Main.elm ├── Editor │ ├── Style.elm │ ├── Model.elm │ ├── History.elm │ ├── Function.elm │ ├── Config.elm │ ├── SearchX.elm │ ├── Widget.elm │ ├── Search.elm │ ├── Styles.elm │ ├── Strings.elm │ ├── Keymap.elm │ ├── Wrap.elm │ └── View.elm ├── Util │ └── Array.elm ├── Position.elm ├── BufferHelper.elm ├── Window.elm ├── Buffer2.elm ├── Array │ └── Util.elm └── Editor.elm ├── .gitignore ├── package.json ├── elm.json ├── LICENSE ├── README.md └── tests ├── Buffer2Test.elm └── BufferComparison.elm /demo/make.sh: -------------------------------------------------------------------------------- 1 | elm make --debug src/Demo.elm --output public/Demo.js 2 | -------------------------------------------------------------------------------- /docs/DEVTRICKS.md: -------------------------------------------------------------------------------- 1 | [npx](https://dev.to/sarscode/npx-vs-npm-the-npx-advantage-1h0o) -------------------------------------------------------------------------------- /testSpares/elm-verify-examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "../src", 3 | "tests": ["Buffer2"] 4 | } 5 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # To Run 2 | 3 | ````bash 4 | 5 | npm install 6 | 7 | npm start 8 | 9 | ```` 10 | -------------------------------------------------------------------------------- /demo/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /demo-simple/README.md: -------------------------------------------------------------------------------- 1 | # To Run 2 | 3 | ````bash 4 | 5 | npm install 6 | 7 | npm start 8 | 9 | ```` 10 | -------------------------------------------------------------------------------- /demo-simple/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-simple", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Html exposing (Html) 4 | 5 | 6 | main : Html msg 7 | main = 8 | Html.text "Hello, elm-browser!" 9 | -------------------------------------------------------------------------------- /src/Editor/Style.elm: -------------------------------------------------------------------------------- 1 | module Editor.Style exposing (darkBlue, darkGray, lightBlue, lightGray) 2 | 3 | 4 | lightBlue = 5 | "#8d9ffe" 6 | 7 | 8 | darkBlue = 9 | "#172da3" 10 | 11 | 12 | lightGray = 13 | "#a5a6ab" 14 | 15 | 16 | darkGray = 17 | "#444548" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | elm-stuff/ 3 | .idea/ 4 | 5 | demo/public/Demo.js 6 | demo/scripts/publish.sh 7 | demo/public/Demo.min.js 8 | demo/public/index-remote.html 9 | demno/public/DemoSimple.min.js 10 | 11 | demo-simple/public/index-remote.html 12 | demo-simple/*.sh 13 | demo-simple/public/DemoSimple.js 14 | demo-simple/public/DemoSimple.min.js 15 | 16 | tests/VerifyExamples/ 17 | markdown/ 18 | 19 | docs/NOTES.md 20 | 21 | -------------------------------------------------------------------------------- /src/Util/Array.elm: -------------------------------------------------------------------------------- 1 | module Util.Array exposing (last) 2 | 3 | {-| Basically Array.Extra but for stuff that isn't included in Array.Extra 4 | -} 5 | 6 | import Array exposing (Array) 7 | 8 | 9 | last : Array a -> Maybe ( a, Int ) 10 | last array = 11 | let 12 | length = 13 | Array.length array 14 | in 15 | Array.slice -1 length array 16 | |> Array.get 0 17 | |> Maybe.map (\a -> ( a, length - 1 )) 18 | -------------------------------------------------------------------------------- /demo-simple/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Simple Text Editor Demo 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /testSpares/Tests/NoOp.elm: -------------------------------------------------------------------------------- 1 | module Tests.NoOp exposing (doesNothing) 2 | 3 | import ArchitectureTest exposing (..) 4 | import Expect exposing (Expectation) 5 | import Fuzz exposing (Fuzzer) 6 | import Test exposing (..) 7 | import Tests.Common exposing (..) 8 | 9 | 10 | type alias MsgTestO model msg = 11 | String 12 | -> TestedApp model msg 13 | -> Fuzzer msg 14 | -> (model -> msg -> model -> Expectation) 15 | -> Test 16 | 17 | 18 | doesNothing : Test 19 | doesNothing = 20 | msgTest "NoOp does nothing" app noOp <| 21 | \modelBeforeMsg _ finalModel -> 22 | modelBeforeMsg 23 | |> Expect.equal finalModel 24 | -------------------------------------------------------------------------------- /demo/public/assets/math-text.js: -------------------------------------------------------------------------------- 1 | class MathText extends HTMLElement { 2 | 3 | // The paragraph below detects the 4 | // argument to the custom element 5 | // and is necessary for innerHTML 6 | // to receive the argument. 7 | set content(value) { 8 | this.innerHTML = value 9 | } 10 | 11 | connectedCallback() { 12 | this.attachShadow({mode: "open"}); 13 | this.shadowRoot.innerHTML = 14 | '' + this.innerHTML + ''; 15 | MathJax.typesetShadow(this.shadowRoot); 16 | // setTimeout(() => MathJax.typesetShadow(this.shadowRoot), 1); 17 | } 18 | } 19 | 20 | customElements.define('math-text', MathText) 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elm-text-editor", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "test": "npx elm-test" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/jxxcarlson/elm-text-editor.git" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/jxxcarlson/elm-text-editor/issues" 21 | }, 22 | "homepage": "https://github.com/jxxcarlson/elm-text-editor#readme", 23 | "dependencies": { 24 | "elm-json": "^0.2.6" 25 | }, 26 | "devDependencies": { 27 | "elm-test": "^0.19.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /demo/public/assets/math-text-delayed.js: -------------------------------------------------------------------------------- 1 | class MathTextDelayed extends HTMLElement { 2 | 3 | // The "set content" code below detects the 4 | // argument to the custom element 5 | // and is necessary for innerHTML 6 | // to receive the argument. 7 | set content(value) { 8 | this.innerHTML = value 9 | } 10 | 11 | connectedCallback() { 12 | this.attachShadow({mode: "open"}); 13 | this.shadowRoot.innerHTML = 14 | '' + this.innerHTML + ''; 15 | // MathJax.typesetShadow(this.shadowRoot); 16 | setTimeout(() => MathJax.typesetShadow(this.shadowRoot), 1); 17 | } 18 | } 19 | 20 | customElements.define('math-text-delayed', MathTextDelayed) 21 | 22 | -------------------------------------------------------------------------------- /demo-simple/scripts/publish.sh: -------------------------------------------------------------------------------- 1 | color=`tput setaf 48` 2 | reset=`tput setaf 7` 3 | 4 | echo 5 | echo "${color}:Publishing ...${reset}" 6 | 7 | SOURCE=/Users/carlson/dev/elm/mylibraries/elm-text-editor/demo-simple/src 8 | PUBLIC=/Users/carlson/dev/elm/mylibraries/elm-text-editor/demo-simple/public 9 | TARGET=/Users/carlson/dev/github_pages/app/editor-simple 10 | 11 | elm make --optimize ${SOURCE}/DemoSimple.elm --output=${PUBLIC}/DemoSimple.js 12 | 13 | echo 14 | echo "${color}:Uglifying ...${reset}" 15 | 16 | uglifyjs ${PUBLIC}/DemoSimple.js -mc 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9"' -o ${PUBLIC}/DemoSimple.min.js 17 | 18 | echo 19 | echo "${color}:Copying ...${reset}" 20 | 21 | cp ${PUBLIC}/index-remote.html ${TARGET}/index.html 22 | cp ${PUBLIC}/DemoSimple.min.js ${TARGET}/ 23 | 24 | 25 | echo 26 | echo "${color}cd /Users/carlson/dev/github_pages${reset}" 27 | 28 | 29 | cd /Users/carlson/dev/github_pages 30 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Pure Elm Text Editor 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "1.0.0", 4 | "description": "Compile with elm make --optimize Main.elm --output=Demo.js", 5 | "main": "Demo.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "elm-live -d public/ src/Demo.elm -- --output public/Demo.js", 9 | "debug": "elm make --debug src/Demo.elm --output public/Demo.js", 10 | "compile": "elm make --optimize src/Demo.elm --output public/Demo.js", 11 | "ff": "open -a /Applications/Firefox.app/ public/index.html", 12 | "chrome": "open -a /Applications/GoogleChrome.app/ public/index.html", 13 | "set-dev": "cp elm-dev.json elm.json", 14 | "set-pub": "cp elm-pub.json elm.json", 15 | "update-pub": "cp elm.json elm-pub.json", 16 | "publish": "sh scripts/publish.sh", 17 | "go" : "open -a /Applications/Firefox.app https://jxxcarlson.github.io/app/editor/index.html" 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "BSD-3" 22 | } 23 | -------------------------------------------------------------------------------- /demo-simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-simple", 3 | "version": "1.0.0", 4 | "description": "Compile with elm make --optimize Main.elm --output=Demo.js", 5 | "main": "Demo.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "elm-live -d public/ src/DemoSimple.elm -- --output public/DemoSimple.js", 9 | "debug": "elm make --debug src/DemoSimple.elm --output public/DemoSimple.js", 10 | "compile": "elm make --optimize src/DemoSimple.elm --output public/DemoSimple.js", 11 | "ff": "open -a /Applications/Firefox.app/ public/index.html", 12 | "chrome": "open -a /Applications/GoogleChrome.app/ public/index.html", 13 | "set-dev": "cp elm-dev.json elm.json", 14 | "set-pub": "cp elm-pub.json elm.json", 15 | "update-pub": "cp elm.json elm-pub.json", 16 | "go" : "open -a /Applications/GoogleChrome.app https://jxxcarlson.github.io/app/editor-simple/index.html", 17 | "publish": "sh scripts/publish.sh" 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "ISC" 22 | } 23 | -------------------------------------------------------------------------------- /src/Editor/Model.elm: -------------------------------------------------------------------------------- 1 | module Editor.Model exposing (InternalState, Snapshot) 2 | 3 | import Buffer exposing (Buffer) 4 | import Debounce exposing (Debounce) 5 | import Editor.Config exposing (Config) 6 | import Editor.History exposing (History) 7 | import Position exposing (Position) 8 | import RollingList exposing (RollingList) 9 | 10 | 11 | type alias Snapshot = 12 | { cursor : Position 13 | , selection : Maybe Position 14 | , buffer : Buffer 15 | } 16 | 17 | 18 | type alias InternalState = 19 | { config : Config 20 | , topLine : Int 21 | , cursor : Position 22 | , selection : Maybe Position 23 | , selectedText : Maybe String 24 | , clipboard : String 25 | , currentLine : Maybe String 26 | , dragging : Bool 27 | , history : History Snapshot 28 | , searchTerm : String 29 | , searchHitIndex : Int 30 | , replacementText : String 31 | , canReplace : Bool 32 | , searchResults : RollingList ( Position, Position ) 33 | , showHelp : Bool 34 | , showInfoPanel : Bool 35 | , showGoToLinePanel : Bool 36 | , showSearchPanel : Bool 37 | , savedBuffer : Buffer 38 | , debounce : Debounce String 39 | } 40 | -------------------------------------------------------------------------------- /testSpares/Tests/Insert.elm: -------------------------------------------------------------------------------- 1 | module Tests.Insert exposing (one) 2 | 3 | import ArchitectureTest exposing (..) 4 | import Editor 5 | import Expect exposing (Expectation) 6 | import Fuzz exposing (Fuzzer) 7 | import Test exposing (..) 8 | import Tests.Common exposing (..) 9 | 10 | 11 | one : Test 12 | one = 13 | msgTest "inserting one character properly advances the cursor" app insertOne <| 14 | \initialModel _ finalModel -> 15 | let 16 | initialCursor = 17 | Editor.getCursor initialModel 18 | 19 | finalCursor = 20 | Editor.getCursor finalModel 21 | 22 | ok = 23 | case ( finalCursor.line - initialCursor.line, finalCursor.column - initialCursor.column, finalCursor.column ) of 24 | ( 0, 1, _ ) -> 25 | True 26 | 27 | ( 0, _, _ ) -> 28 | False 29 | 30 | ( 1, _, 0 ) -> 31 | True 32 | 33 | _ -> 34 | False 35 | in 36 | ok 37 | |> Expect.equal True 38 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "jxxcarlson/elm-text-editor", 4 | "summary": "Pure Elm text editor forked from Sydney Nemzer", 5 | "license": "BSD-3-Clause", 6 | "version": "7.0.8", 7 | "exposed-modules": [ 8 | "Editor", 9 | "Editor.Strings", 10 | "Editor.Config", 11 | "Editor.Update" 12 | ], 13 | "elm-version": "0.19.0 <= v < 0.20.0", 14 | "dependencies": { 15 | "Janiczek/architecture-test": "2.1.0 <= v < 3.0.0", 16 | "carwow/elm-slider": "10.0.0 <= v < 11.0.0", 17 | "elm/browser": "1.0.0 <= v < 2.0.0", 18 | "elm/core": "1.0.0 <= v < 2.0.0", 19 | "elm/html": "1.0.0 <= v < 2.0.0", 20 | "elm/json": "1.0.0 <= v < 2.0.0", 21 | "elm-community/list-extra": "8.1.0 <= v < 9.0.0", 22 | "elm-community/maybe-extra": "5.0.0 <= v < 6.0.0", 23 | "elm-community/string-extra": "4.0.0 <= v < 5.0.0", 24 | "elm-explorations/test": "1.2.2 <= v < 2.0.0", 25 | "folkertdev/elm-paragraph": "1.0.0 <= v < 2.0.0", 26 | "jinjor/elm-debounce": "3.0.0 <= v < 4.0.0", 27 | "lovasoa/elm-rolling-list": "1.1.4 <= v < 2.0.0", 28 | "lukewestby/elm-string-interpolate": "1.0.4 <= v < 2.0.0" 29 | }, 30 | "test-dependencies": {} 31 | } 32 | -------------------------------------------------------------------------------- /demo-simple/elm-dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "../src" 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "elm/browser": "1.0.2", 11 | "elm/core": "1.0.4", 12 | "elm/html": "1.0.0", 13 | "elm/json": "1.1.3", 14 | "elm-community/list-extra": "8.2.3", 15 | "elm-community/maybe-extra": "5.1.0", 16 | "elm-community/string-extra": "4.0.1", 17 | "folkertdev/elm-paragraph": "1.0.0", 18 | "jinjor/elm-debounce": "3.0.0", 19 | "lovasoa/elm-rolling-list": "1.1.4", 20 | "lukewestby/elm-string-interpolate": "1.0.4" 21 | }, 22 | "indirect": { 23 | "Janiczek/architecture-test": "2.1.0", 24 | "carwow/elm-slider": "10.0.0", 25 | "debois/elm-dom": "1.3.0", 26 | "elm/random": "1.0.0", 27 | "elm/regex": "1.0.0", 28 | "elm/time": "1.0.0", 29 | "elm/url": "1.0.0", 30 | "elm/virtual-dom": "1.0.2", 31 | "elm-explorations/test": "1.2.2" 32 | } 33 | }, 34 | "test-dependencies": { 35 | "direct": {}, 36 | "indirect": {} 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /demo-simple/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/browser": "1.0.2", 10 | "elm/core": "1.0.4", 11 | "elm/html": "1.0.0", 12 | "elm/json": "1.1.3", 13 | "jxxcarlson/elm-text-editor": "7.0.8" 14 | }, 15 | "indirect": { 16 | "Janiczek/architecture-test": "2.1.0", 17 | "carwow/elm-slider": "10.0.0", 18 | "debois/elm-dom": "1.3.0", 19 | "elm/random": "1.0.0", 20 | "elm/regex": "1.0.0", 21 | "elm/time": "1.0.0", 22 | "elm/url": "1.0.0", 23 | "elm/virtual-dom": "1.0.2", 24 | "elm-community/list-extra": "8.2.3", 25 | "elm-community/maybe-extra": "5.1.0", 26 | "elm-community/string-extra": "4.0.1", 27 | "elm-explorations/test": "1.2.2", 28 | "folkertdev/elm-paragraph": "1.0.0", 29 | "jinjor/elm-debounce": "3.0.0", 30 | "lovasoa/elm-rolling-list": "1.1.4", 31 | "lukewestby/elm-string-interpolate": "1.0.4" 32 | } 33 | }, 34 | "test-dependencies": { 35 | "direct": {}, 36 | "indirect": {} 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /demo-simple/elm-pub.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/browser": "1.0.2", 10 | "elm/core": "1.0.4", 11 | "elm/html": "1.0.0", 12 | "elm/json": "1.1.3", 13 | "jxxcarlson/elm-text-editor": "7.0.7" 14 | }, 15 | "indirect": { 16 | "Janiczek/architecture-test": "2.1.0", 17 | "carwow/elm-slider": "10.0.0", 18 | "debois/elm-dom": "1.3.0", 19 | "elm/random": "1.0.0", 20 | "elm/regex": "1.0.0", 21 | "elm/time": "1.0.0", 22 | "elm/url": "1.0.0", 23 | "elm/virtual-dom": "1.0.2", 24 | "elm-community/list-extra": "8.2.3", 25 | "elm-community/maybe-extra": "5.1.0", 26 | "elm-community/string-extra": "4.0.1", 27 | "elm-explorations/test": "1.2.2", 28 | "folkertdev/elm-paragraph": "1.0.0", 29 | "jinjor/elm-debounce": "3.0.0", 30 | "lovasoa/elm-rolling-list": "1.1.4", 31 | "lukewestby/elm-string-interpolate": "1.0.4" 32 | } 33 | }, 34 | "test-dependencies": { 35 | "direct": {}, 36 | "indirect": {} 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 James Carlson 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /src/Editor/History.elm: -------------------------------------------------------------------------------- 1 | module Editor.History exposing (History, empty, push, redo, undo) 2 | 3 | {-| Making changes adds entries to the past. Undoing a few times adds those 4 | entries to the future. Making another change adds a new entry to the past and 5 | deletes the future. Redoing moves entries from the future to the past. 6 | Past entries are older at higher indexes, future entries are newer at higher 7 | indexes 8 | -} 9 | 10 | 11 | type alias InternalHistory a = 12 | { past : List a, future : List a } 13 | 14 | 15 | type History a 16 | = History (InternalHistory a) 17 | 18 | 19 | empty : History a 20 | empty = 21 | History { past = [], future = [] } 22 | 23 | 24 | push : a -> History a -> History a 25 | push entry (History history) = 26 | History { past = entry :: history.past, future = [] } 27 | 28 | 29 | undo : a -> History a -> Maybe ( History a, a ) 30 | undo current (History history) = 31 | case history.past of 32 | previous :: past -> 33 | Just 34 | ( History { past = past, future = current :: history.future } 35 | , previous 36 | ) 37 | 38 | [] -> 39 | Nothing 40 | 41 | 42 | redo : a -> History a -> Maybe ( History a, a ) 43 | redo current (History history) = 44 | case history.future of 45 | next :: future -> 46 | Just 47 | ( History { past = current :: history.past, future = future } 48 | , next 49 | ) 50 | 51 | [] -> 52 | Nothing 53 | -------------------------------------------------------------------------------- /src/Editor/Function.elm: -------------------------------------------------------------------------------- 1 | module Editor.Function exposing (bufferOf, cursorRight, stateOf) 2 | 3 | import Buffer exposing (Buffer) 4 | import Editor.Model exposing (InternalState) 5 | import Editor.Update exposing (Msg(..)) 6 | import Position 7 | 8 | 9 | stateOf : ( InternalState, Buffer, Cmd Msg ) -> InternalState 10 | stateOf ( state, _, _ ) = 11 | state 12 | 13 | 14 | bufferOf : ( InternalState, Buffer, Cmd Msg ) -> Buffer 15 | bufferOf ( _, buffer, _ ) = 16 | buffer 17 | 18 | 19 | cursorRight state buffer = 20 | let 21 | newCursor = 22 | let 23 | moveFrom = 24 | case state.selection of 25 | Just selection -> 26 | Position.order selection state.cursor 27 | |> Tuple.second 28 | 29 | Nothing -> 30 | state.cursor 31 | in 32 | Position.nextColumn moveFrom 33 | |> Buffer.clampPosition Buffer.Forward buffer 34 | 35 | -- cmd = 36 | -- case state.cursor.line /= newCursor.line of 37 | -- True -> 38 | -- setEditorViewportForLine state.config.lineHeight newCursor.line 39 | -- 40 | -- False -> 41 | -- Cmd.none 42 | in 43 | ( { state 44 | | cursor = newCursor 45 | , selection = Nothing 46 | } 47 | , buffer 48 | , Cmd.none 49 | ) 50 | -------------------------------------------------------------------------------- /demo/elm-dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "../src" 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "Janiczek/cmd-extra": "1.1.0", 11 | "elm/browser": "1.0.2", 12 | "elm/core": "1.0.4", 13 | "elm/html": "1.0.0", 14 | "elm/json": "1.1.3", 15 | "elm/parser": "1.1.0", 16 | "elm/time": "1.0.0", 17 | "elm-community/list-extra": "8.2.3", 18 | "elm-community/maybe-extra": "5.1.0", 19 | "elm-community/string-extra": "4.0.1", 20 | "folkertdev/elm-paragraph": "1.0.0", 21 | "jinjor/elm-debounce": "3.0.0", 22 | "jxxcarlson/elm-markdown": "6.0.1", 23 | "jxxcarlson/htree": "2.0.1", 24 | "lovasoa/elm-rolling-list": "1.1.4", 25 | "lukewestby/elm-string-interpolate": "1.0.4", 26 | "pablohirafuji/elm-syntax-highlight": "3.2.0", 27 | "zwilias/elm-rosetree": "1.5.0" 28 | }, 29 | "indirect": { 30 | "NoRedInk/elm-string-conversions": "1.0.1", 31 | "elm/bytes": "1.0.8", 32 | "elm/file": "1.0.5", 33 | "elm/http": "2.0.0", 34 | "elm/regex": "1.0.0", 35 | "elm/url": "1.0.0", 36 | "elm/virtual-dom": "1.0.2", 37 | "zwilias/elm-html-string": "2.0.2" 38 | } 39 | }, 40 | "test-dependencies": { 41 | "direct": {}, 42 | "indirect": { 43 | "Janiczek/architecture-test": "2.1.0", 44 | "carwow/elm-slider": "10.0.0", 45 | "debois/elm-dom": "1.3.0", 46 | "elm/random": "1.0.0", 47 | "elm-explorations/test": "1.2.2" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /demo/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "Janiczek/cmd-extra": "1.1.0", 10 | "elm/browser": "1.0.2", 11 | "elm/core": "1.0.4", 12 | "elm/html": "1.0.0", 13 | "elm/json": "1.1.3", 14 | "elm/time": "1.0.0", 15 | "jxxcarlson/elm-markdown": "6.0.1", 16 | "jxxcarlson/elm-text-editor": "7.0.8", 17 | "zwilias/elm-rosetree": "1.5.0" 18 | }, 19 | "indirect": { 20 | "Janiczek/architecture-test": "2.1.0", 21 | "NoRedInk/elm-string-conversions": "1.0.1", 22 | "carwow/elm-slider": "10.0.0", 23 | "debois/elm-dom": "1.3.0", 24 | "elm/bytes": "1.0.8", 25 | "elm/file": "1.0.5", 26 | "elm/http": "2.0.0", 27 | "elm/parser": "1.1.0", 28 | "elm/random": "1.0.0", 29 | "elm/regex": "1.0.0", 30 | "elm/url": "1.0.0", 31 | "elm/virtual-dom": "1.0.2", 32 | "elm-community/list-extra": "8.2.3", 33 | "elm-community/maybe-extra": "5.1.0", 34 | "elm-community/string-extra": "4.0.1", 35 | "elm-explorations/test": "1.2.2", 36 | "folkertdev/elm-paragraph": "1.0.0", 37 | "jinjor/elm-debounce": "3.0.0", 38 | "jxxcarlson/htree": "2.0.1", 39 | "lovasoa/elm-rolling-list": "1.1.4", 40 | "lukewestby/elm-string-interpolate": "1.0.4", 41 | "pablohirafuji/elm-syntax-highlight": "3.2.0", 42 | "zwilias/elm-html-string": "2.0.2" 43 | } 44 | }, 45 | "test-dependencies": { 46 | "direct": {}, 47 | "indirect": {} 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /demo/elm-pub.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "Janiczek/cmd-extra": "1.1.0", 10 | "elm/browser": "1.0.2", 11 | "elm/core": "1.0.4", 12 | "elm/html": "1.0.0", 13 | "elm/json": "1.1.3", 14 | "elm/time": "1.0.0", 15 | "jxxcarlson/elm-markdown": "6.0.0", 16 | "jxxcarlson/elm-text-editor": "7.0.7", 17 | "zwilias/elm-rosetree": "1.5.0" 18 | }, 19 | "indirect": { 20 | "Janiczek/architecture-test": "2.1.0", 21 | "NoRedInk/elm-string-conversions": "1.0.1", 22 | "carwow/elm-slider": "10.0.0", 23 | "debois/elm-dom": "1.3.0", 24 | "elm/bytes": "1.0.8", 25 | "elm/file": "1.0.5", 26 | "elm/http": "2.0.0", 27 | "elm/parser": "1.1.0", 28 | "elm/random": "1.0.0", 29 | "elm/regex": "1.0.0", 30 | "elm/url": "1.0.0", 31 | "elm/virtual-dom": "1.0.2", 32 | "elm-community/list-extra": "8.2.3", 33 | "elm-community/maybe-extra": "5.1.0", 34 | "elm-community/string-extra": "4.0.1", 35 | "elm-explorations/test": "1.2.2", 36 | "folkertdev/elm-paragraph": "1.0.0", 37 | "jinjor/elm-debounce": "3.0.0", 38 | "jxxcarlson/htree": "2.0.1", 39 | "lovasoa/elm-rolling-list": "1.1.4", 40 | "lukewestby/elm-string-interpolate": "1.0.4", 41 | "pablohirafuji/elm-syntax-highlight": "3.2.0", 42 | "zwilias/elm-html-string": "2.0.2" 43 | } 44 | }, 45 | "test-dependencies": { 46 | "direct": {}, 47 | "indirect": {} 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /testSpares/EditorTest.elm: -------------------------------------------------------------------------------- 1 | module EditorTest exposing (suite) 2 | 3 | import Expect exposing (Expectation) 4 | import Test exposing (..) 5 | import Tests.CursorLeft as CursorLeft 6 | import Tests.CursorRight as CursorRight 7 | import Tests.Insert as Insert 8 | import Tests.Invariants as Invariants 9 | import Tests.NoOp as NoOp 10 | 11 | 12 | 13 | --suite : Test 14 | --suite = 15 | -- describe "String.reverse" 16 | -- -- Nest as many descriptions as you like. 17 | -- [ test "has no effect on a palindrome" <| 18 | -- \_ -> 19 | -- let 20 | -- palindrome = 21 | -- "hannah" 22 | -- in 23 | -- Expect.equal palindrome (String.reverse palindrome) 24 | -- ] 25 | -- 26 | 27 | 28 | suite : Test 29 | suite = 30 | concat 31 | [ -- describe "Invariants" 32 | -- [ describe "cursor" 33 | -- [ describe "line" 34 | -- [] 35 | -- , describe "column" 36 | -- [] 37 | -- ] 38 | -- ] 39 | describe "skip" 40 | [ describe "NoOp" 41 | [ NoOp.doesNothing 42 | ] 43 | , describe "Invariants" 44 | [ Invariants.cursorLineIsAlwaysPositive 45 | , Invariants.cursorColumnIsAlwaysPositive 46 | , Invariants.cursorLineNeverGetsToNonexistingLine 47 | , Invariants.cursorLineNeverGetsToBeyondEndOfLine 48 | ] 49 | , describe "Insert" 50 | [ Insert.one ] 51 | , describe 52 | "CursorLeft" 53 | [ CursorLeft.notNegativeColumn ] 54 | , describe "CursorRight" 55 | [ CursorRight.advance ] 56 | ] 57 | ] 58 | -------------------------------------------------------------------------------- /demo/public/assets/outside.js: -------------------------------------------------------------------------------- 1 | // OUTSIDE 2 | 3 | app.ports.infoForOutside.subscribe(msg => { 4 | 5 | console.log("app.ports.infoForOutside") 6 | 7 | switch(msg.tag) { 8 | 9 | case "AskForClipBoard": 10 | console.log("AskForClipBoard") 11 | 12 | navigator.clipboard.readText() 13 | .then(text => { 14 | console.log('Clipboard (outside):', text); 15 | app.ports.infoForElm.send({tag: "GotClipboard", data: text}) 16 | }) 17 | .catch(err => { 18 | console.error('Failed to read clipboard: ', err); 19 | }); 20 | 21 | break; 22 | 23 | case "WriteToClipboard": 24 | console.log("WriteToClipboard", JSON.stringify(msg.data)) 25 | 26 | navigator.permissions.query({name: "clipboard-write"}).then(result => { 27 | if (result.state == "granted" || result.state == "prompt") { 28 | updateClipboard(JSON.stringify(msg.data)) 29 | } 30 | }); 31 | 32 | 33 | break; 34 | 35 | case "Highlight": 36 | 37 | console.log("Highlight", msg.data) 38 | var id = "#".concat(msg.data.id) 39 | var lastId = msg.data.lastId 40 | console.log("Highlight (id, lastId)", id, lastId) 41 | 42 | var element = document.querySelector(id) 43 | if (element != null) { 44 | element.classList.add("highlight") 45 | } else { 46 | console.log("Add: could not find id", id) 47 | } 48 | 49 | var lastElement = document.querySelector(lastId) 50 | if (lastElement != null) { 51 | lastElement.classList.remove("highlight") 52 | } else { 53 | console.log("Remove: could not find last id",lastId) 54 | } 55 | 56 | break; 57 | 58 | } 59 | 60 | function updateClipboard(newClip) { 61 | navigator.clipboard.writeText(newClip).then(function() { 62 | console.log("Wrote to clipboard"); 63 | }, function() { 64 | console.log ("Clipboard write failed"); 65 | }); 66 | } 67 | 68 | }) 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/Editor/Config.elm: -------------------------------------------------------------------------------- 1 | module Editor.Config exposing (Config, WrapOption(..), WrapParams, default, setMaximumWrapWidth, setOptimumWrapWidth, setWrapOption) 2 | 3 | {-| Use this module to configure the editor. 4 | The `default` value is a basic configuration 5 | which you can modify like this: 6 | 7 | config = 8 | { default | lines = 30 } 9 | 10 | @docs Config, WrapOption, WrapParams, default, setMaximumWrapWidth, setOptimumWrapWidth, setWrapOption 11 | 12 | -} 13 | 14 | 15 | {-| -} 16 | type alias Config = 17 | { wrapParams : WrapParams 18 | , showInfoPanel : Bool 19 | , wrapOption : WrapOption 20 | , width : Float 21 | , height : Float 22 | , lineHeight : Float 23 | , fontProportion : Float 24 | , lineHeightFactor : Float 25 | } 26 | 27 | 28 | {-| -} 29 | type alias WrapParams = 30 | { maximumWidth : Int 31 | , optimalWidth : Int 32 | , stringWidth : String -> Int 33 | } 34 | 35 | 36 | {-| -} 37 | type WrapOption 38 | = DoWrap 39 | | DontWrap 40 | 41 | 42 | {-| -} 43 | default : Config 44 | default = 45 | { wrapParams = { maximumWidth = 50, optimalWidth = 45, stringWidth = String.length } 46 | , showInfoPanel = False 47 | , wrapOption = DoWrap 48 | , width = 400 49 | , height = 400 50 | , lineHeight = 18 51 | , fontProportion = 0.7 52 | , lineHeightFactor = 1.0 53 | } 54 | 55 | 56 | 57 | -- TODO: Make maximumWidth and optimalWidth configurable at startup and at runtime 58 | 59 | 60 | {-| -} 61 | setWrapOption : WrapOption -> Config -> Config 62 | setWrapOption wrapOption config = 63 | { config | wrapOption = wrapOption } 64 | 65 | 66 | {-| -} 67 | setMaximumWrapWidth : Int -> Config -> Config 68 | setMaximumWrapWidth k config = 69 | let 70 | w = 71 | config.wrapParams 72 | 73 | newWrapParams = 74 | { w | maximumWidth = k } 75 | in 76 | { config | wrapParams = newWrapParams } 77 | 78 | 79 | {-| -} 80 | setOptimumWrapWidth : Int -> Config -> Config 81 | setOptimumWrapWidth k config = 82 | let 83 | w = 84 | config.wrapParams 85 | 86 | newWrapParams = 87 | { w | optimalWidth = k } 88 | in 89 | { config | wrapParams = newWrapParams } 90 | -------------------------------------------------------------------------------- /demo/src/Outside.elm: -------------------------------------------------------------------------------- 1 | port module Outside exposing 2 | ( InfoForElm(..) 3 | , InfoForOutside(..) 4 | , getInfo 5 | , sendInfo 6 | ) 7 | 8 | {-| This module manages all interactions with the external JS-world. 9 | At the moment, there is just one: external copy-paste. 10 | -} 11 | 12 | import Json.Decode as D 13 | import Json.Encode as E 14 | 15 | 16 | port infoForOutside : GenericOutsideData -> Cmd msg 17 | 18 | 19 | port infoForElm : (GenericOutsideData -> msg) -> Sub msg 20 | 21 | 22 | type alias GenericOutsideData = 23 | { tag : String, data : E.Value } 24 | 25 | 26 | type InfoForElm 27 | = GotClipboard String 28 | 29 | 30 | type InfoForOutside 31 | = AskForClipBoard E.Value 32 | | WriteToClipBoard String 33 | | Highlight ( Maybe String, String ) 34 | 35 | 36 | getInfo : (InfoForElm -> msg) -> (String -> msg) -> Sub msg 37 | getInfo tagger onError = 38 | infoForElm 39 | (\outsideInfo -> 40 | case outsideInfo.tag of 41 | "GotClipboard" -> 42 | case D.decodeValue clipboardDecoder outsideInfo.data of 43 | Ok result -> 44 | tagger <| GotClipboard result 45 | 46 | Err e -> 47 | onError <| "" 48 | 49 | _ -> 50 | onError <| "Unexpected info from outside" 51 | ) 52 | 53 | 54 | sendInfo : InfoForOutside -> Cmd msg 55 | sendInfo info = 56 | case info of 57 | AskForClipBoard value -> 58 | infoForOutside { tag = "AskForClipBoard", data = E.null } 59 | 60 | WriteToClipBoard str -> 61 | infoForOutside { tag = "WriteToClipboard", data = E.string str } 62 | 63 | Highlight idPair -> 64 | infoForOutside { tag = "Highlight", data = encodeSelectedIdData idPair } 65 | 66 | 67 | encodeSelectedIdData : ( Maybe String, String ) -> E.Value 68 | encodeSelectedIdData ( maybeLastId, id ) = 69 | E.object 70 | [ ( "lastId", E.string (maybeLastId |> Maybe.withDefault "nonexistent") ) 71 | , ( "id", E.string id ) 72 | ] 73 | 74 | 75 | 76 | -- DECODERS -- 77 | 78 | 79 | clipboardDecoder : D.Decoder String 80 | clipboardDecoder = 81 | -- D.field "data" D.string 82 | D.string 83 | -------------------------------------------------------------------------------- /testSpares/Tests/CursorLeft.elm: -------------------------------------------------------------------------------- 1 | module Tests.CursorLeft exposing (notNegativeColumn) 2 | 3 | import ArchitectureTest exposing (..) 4 | import Buffer 5 | import Editor exposing (..) 6 | import Expect exposing (Expectation) 7 | import Fuzz exposing (Fuzzer) 8 | import Test exposing (..) 9 | import Tests.Common exposing (..) 10 | 11 | 12 | retreat : Test 13 | retreat = 14 | msgTest "CursorLeft moves the cursor back one unit" app cursorLeft <| 15 | \initialModel cursorLeft finalModel -> 16 | let 17 | initialCursor = 18 | Editor.getCursor initialModel 19 | 20 | finalCursor = 21 | Editor.getCursor finalModel 22 | 23 | finallLineLength = 24 | Buffer.lineEnd initialCursor.line (Editor.getBuffer initialModel) 25 | 26 | cursorWasAtEndOfLine = 27 | -- ask if the initial cursor was at the end of the line 28 | case Maybe.map2 (-) finallLineLength (Just finalCursor.column) of 29 | Just 0 -> 30 | True 31 | 32 | _ -> 33 | False 34 | 35 | ok = 36 | case ( finalCursor.line - initialCursor.line, initialCursor.column - finalCursor.column, cursorWasAtEndOfLine ) of 37 | ( 0, 1, _ ) -> 38 | -- move back in same line 39 | True 40 | 41 | ( 1, _, True ) -> 42 | -- move back to end of previous line 43 | True 44 | 45 | ( 0, 0, _ ) -> 46 | -- cursor was at beginning of text: can't move any more 47 | True 48 | 49 | _ -> 50 | False 51 | in 52 | ok 53 | |> Expect.equal True 54 | 55 | 56 | notNegativeColumn : Test 57 | notNegativeColumn = 58 | msgTest "CursorLeft never results in negative cursor columns" app cursorLeft <| 59 | \_ _ finalModel -> 60 | let 61 | finalCursor = 62 | Editor.getCursor finalModel 63 | in 64 | finalCursor.column 65 | |> Expect.greaterThan -1 66 | -------------------------------------------------------------------------------- /docs/README_SYDNEY_NEMZER.md: -------------------------------------------------------------------------------- 1 | # elm-text-editor 2 | 3 | A flexible text editor written in Elm 4 | 5 | [Check out the demo](https://sidneynemzer.github.io/elm-text-editor/) 6 | 7 | > Note: 8 | > * This project is not published yet on package.elm-lang.org 9 | > * I would not consider this project ready for use in production, since it's missing major features like scrolling and line wrap 10 | 11 | ## Features / Architecture 12 | 13 | This library implements an editor (duh) and a buffer. The buffer is separate from the editor to allow multiple editors to use the same buffer, such as in a multi-panel text editor. 14 | 15 | > Note: There's a checkmark next to implemented features 16 | 17 | ### Buffer 18 | 19 | - [x] file content 20 | - [ ] save status 21 | - [ ] syntax highlighting (cached) 22 | - [ ] decorations (eg underlines, tooltips, gutter icons) 23 | 24 | The buffer implementation has helper functions for manipulating its content, like finding the end of a word. 25 | 26 | ### Editor 27 | 28 | - [x] cursor location 29 | - [x] selection 30 | - [x] rendering to the DOM 31 | - [x] UI interaction (mouse and keyboard) 32 | - [x] undo history 33 | - [ ] scroll position 34 | - [ ] auto-complete dialog 35 | - [ ] open decorations (in other words, decorations exist in the buffer but each editor tracks open decorations) 36 | - [ ] line wrap 37 | 38 | I hope that one day this project will be comparable to Ace and CodeMirror, but Ace and CodeMirror have had quite a head start (about 7 years!). 39 | 40 | ## Previous work and Inspiration 41 | 42 | The editor behavior and keyboard shortcuts are based on [Atom][] (because that's the editor I'm most familiar with). 43 | 44 | [Ace][] and [CodeMirror][] are text editors designed to work in a web browser. They're both written in JavaScript, so integration with Elm is pretty meh. 45 | 46 | [Janiczek][] recently demonstrated [a text editor in pure elm][Janiczek-editor-discourse], which implements work-arounds for several issues I had faced in the past when creating a pure elm editor. 47 | 48 | > I hope this inspires somebody to try some stuff in Elm they’ve been needing but seemed too big / hard for them! You might, like me with this project, find out it’s in your reach - no doubt thanks to Elm 49 | > *- Janiczek* 50 | 51 | [Atom]: https://atom.io 52 | [Ace]: https://ace.c9.io 53 | [CodeMirror]: https://codemirror.net 54 | [Janiczek]: https://github.com/Janiczek 55 | [Janiczek-editor-discourse]: https://discourse.elm-lang.org/t/text-editor-done-in-pure-elm/1365 56 | -------------------------------------------------------------------------------- /src/Position.elm: -------------------------------------------------------------------------------- 1 | module Position exposing 2 | ( Position 3 | , addColumn 4 | , addLine 5 | , between 6 | , nextColumn 7 | , nextLine 8 | , order 9 | , previousColumn 10 | , previousLine 11 | , setColumn 12 | , shift 13 | ) 14 | 15 | 16 | type alias Position = 17 | { line : Int, column : Int } 18 | 19 | 20 | order : Position -> Position -> ( Position, Position ) 21 | order pos1 pos2 = 22 | if pos2.line > pos1.line then 23 | ( pos1, pos2 ) 24 | 25 | else if pos2.line == pos1.line && pos2.column > pos1.column then 26 | ( pos1, pos2 ) 27 | 28 | else 29 | ( pos2, pos1 ) 30 | 31 | 32 | betweenHelp : Int -> Int -> Int -> Bool 33 | betweenHelp start end point = 34 | if start > end then 35 | betweenHelp end start point 36 | 37 | else 38 | start /= end && point >= start && point < end 39 | 40 | 41 | between : Position -> Position -> Position -> Bool 42 | between pos1 pos2 { line, column } = 43 | let 44 | ( start, end ) = 45 | order pos1 pos2 46 | in 47 | if start.line == end.line then 48 | line == start.line && betweenHelp start.column end.column column 49 | 50 | else if start.line == line then 51 | column >= start.column 52 | 53 | else if end.line == line then 54 | column < end.column 55 | 56 | else 57 | betweenHelp start.line end.line line 58 | 59 | 60 | addColumn : Int -> Position -> Position 61 | addColumn amount position = 62 | { position | column = position.column + amount } 63 | 64 | 65 | nextColumn : Position -> Position 66 | nextColumn = 67 | addColumn 1 68 | 69 | 70 | previousColumn : Position -> Position 71 | previousColumn = 72 | addColumn -1 73 | 74 | 75 | setColumn : Int -> Position -> Position 76 | setColumn column position = 77 | { position | column = column } 78 | 79 | 80 | addLine : Int -> Position -> Position 81 | addLine amount position = 82 | { position | line = position.line + amount } 83 | 84 | 85 | nextLine : Position -> Position 86 | nextLine = 87 | addLine 1 88 | 89 | 90 | previousLine : Position -> Position 91 | previousLine = 92 | addLine -1 93 | 94 | 95 | shift : Int -> Position -> Position 96 | shift k position = 97 | case position.line + k >= 0 of 98 | True -> 99 | { position | line = position.line + k } 100 | 101 | False -> 102 | { position | line = 0 } 103 | -------------------------------------------------------------------------------- /testSpares/Tests/Invariants.elm: -------------------------------------------------------------------------------- 1 | module Tests.Invariants exposing 2 | ( cursorColumnIsAlwaysPositive 3 | , cursorLineIsAlwaysPositive 4 | , cursorLineNeverGetsToBeyondEndOfLine 5 | , cursorLineNeverGetsToNonexistingLine 6 | ) 7 | 8 | import ArchitectureTest exposing (..) 9 | import Buffer 10 | import Editor exposing (..) 11 | import Expect exposing (Expectation) 12 | import List.Extra 13 | import Test exposing (..) 14 | import Tests.Common exposing (..) 15 | 16 | 17 | cursorLineIsAlwaysPositive : Test 18 | cursorLineIsAlwaysPositive = 19 | invariantTest "cursor.line is always positive" app <| 20 | \_ _ finalModel -> 21 | let 22 | cursor = 23 | Editor.getCursor finalModel 24 | in 25 | cursor.line 26 | |> Expect.atLeast 0 27 | 28 | 29 | cursorColumnIsAlwaysPositive : Test 30 | cursorColumnIsAlwaysPositive = 31 | invariantTest "cursor.column is always positive" app <| 32 | \_ _ finalModel -> 33 | let 34 | cursor = 35 | Editor.getCursor finalModel 36 | in 37 | cursor.column 38 | |> Expect.atLeast 0 39 | 40 | 41 | cursorLineNeverGetsToNonexistingLine : Test 42 | cursorLineNeverGetsToNonexistingLine = 43 | invariantTest "cursor.line never gets to nonexisting line" app <| 44 | \_ _ finalModel -> 45 | let 46 | cursor = 47 | Editor.getCursor finalModel 48 | 49 | lastLine = 50 | Editor.getBuffer finalModel 51 | |> Buffer.lastPosition 52 | |> .line 53 | in 54 | cursor.line 55 | |> Expect.atMost lastLine 56 | 57 | 58 | cursorLineNeverGetsToBeyondEndOfLine : Test 59 | cursorLineNeverGetsToBeyondEndOfLine = 60 | invariantTest "cursor.line never gets beyond end of line" app <| 61 | \_ _ finalModel -> 62 | let 63 | cursor = 64 | Editor.getCursor finalModel 65 | 66 | currentLineLength : Int 67 | currentLineLength = 68 | Editor.getBuffer finalModel 69 | |> Buffer.lines 70 | |> List.Extra.getAt cursor.line 71 | |> Maybe.map String.length 72 | |> Maybe.withDefault 1000000000 73 | in 74 | cursor.column 75 | |> Expect.atMost currentLineLength 76 | -------------------------------------------------------------------------------- /src/Editor/SearchX.elm: -------------------------------------------------------------------------------- 1 | module Editor.SearchX exposing (search) 2 | 3 | import Buffer exposing (Buffer, lines) 4 | import Position exposing (Position) 5 | 6 | 7 | {-| Return a list of pairs (k, s), where s 8 | -} 9 | 10 | 11 | 12 | -- searchHits : String -> String -> List (Position, Position) 13 | 14 | 15 | search : String -> Buffer -> List ( Position, Position ) 16 | search key buffer = 17 | searchHits key (lines buffer) 18 | 19 | 20 | {-| 21 | 22 | searchHits "AB" ["about this, we know", "that Babs is the best in the lab", "a stitch in time saves nine"] 23 | --> [({ column = 0, line = 0 },{ column = 5, line = 0 }),({ column = 29, line = 1 },{ column = 32, line = 1 })] 24 | 25 | -} 26 | searchHits : String -> List String -> List ( Position, Position ) 27 | searchHits key lines_ = 28 | let 29 | key_ = 30 | key 31 | in 32 | indexedFilterMap (\i line -> String.contains key_ line) lines_ 33 | |> List.map (\( idx, str ) -> ( idx, str, matches key_ str )) 34 | |> List.map positions 35 | |> List.concat 36 | 37 | 38 | {-| 39 | 40 | positions (5, "This is about our lab.", ["about", "lab"]) 41 | --> [({ column = 8, line = 5 },{ column = 13, line = 5 }),({ column = 18, line = 5 },{ column = 21, line = 5 })] 42 | 43 | -} 44 | positions : ( Int, String, List String ) -> List ( Position, Position ) 45 | positions ( line, source, hits ) = 46 | List.map (\hit -> stringIndices hit source) hits 47 | |> List.concat 48 | |> List.map (\( start, end ) -> ( Position line start, Position line end )) 49 | 50 | 51 | {-| 52 | 53 | stringIndices "ab" "This is about our lab." 54 | --> [(8,10),(19,21)] : List ( Int, Int ) 55 | 56 | -} 57 | stringIndices : String -> String -> List ( Int, Int ) 58 | stringIndices key source = 59 | let 60 | n = 61 | String.length key 62 | in 63 | String.indices key source 64 | |> List.map (\i -> ( i, i + n )) 65 | 66 | 67 | {-| 68 | 69 | matches "ab" "abc, hoorAy, yada, Blab about it" 70 | ["abc,","blab","about"] : List String 71 | 72 | -} 73 | matches : String -> String -> List String 74 | matches key str = 75 | str 76 | |> String.words 77 | |> List.filter (\word -> String.contains key word) 78 | 79 | 80 | {-| 81 | 82 | indexedFilterMap (\i x -> i >= 1 && i <= 3) [0,1,2,3,4,5,6] 83 | --> [(1,1),(2,2),(3,3)] 84 | 85 | -} 86 | indexedFilterMap : (Int -> a -> Bool) -> List a -> List ( Int, a ) 87 | indexedFilterMap filter list = 88 | list 89 | |> List.indexedMap (\k item -> ( k, item )) 90 | |> List.filter (\( i, item ) -> filter i item) 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Pure Elm Text Editor 2 | 3 | 4 | This project, a text editor written in pure Elm, is a fork of 5 | [work by Sydney Nemzer](https://github.com/SidneyNemzer/elm-text-editor). 6 | His [demo](https://sidneynemzer.github.io/elm-text-editor/), 7 | inspired by prior work of Martin Janiczek, shows the 8 | feasibility of writing such a text editor and establishes an elegant and powerful foundation for future work. Many kudos to Sydney. 9 | 10 | 11 | This 12 | [forked repo](https://github.com/jxxcarlson/elm-text-editor) adds 13 | scrolling, copy, cut, and paste, search and replace, text wrap, 14 | and an API for embedding the editor in another app. 15 | Here are some examples which make use of the present library. 16 | 17 | - [Demo-simple](https://jxxcarlson.github.io/app/editor-simple/index.html) 18 | 19 | - [Demo](https://jxxcarlson.github.io/app/editor/index.html) This one 20 | implements external copy-paste: copy something somewhere with Cmd-C or 21 | whatever, then use ctrl-shift-V to paste it in the editor. 22 | At the moment, external copy-paste only works in Chrome. 23 | 24 | - [Markdown Example](https://markdown.minilatex.app/) The editor is 25 | hosted by an app that renders text written Markdown. The text may 26 | include mathematics written in TeX/LaTeX. The app features 27 | left-to-right sync: doing ctrl-shift-S in the editor window 28 | (Left) synchronizes the source text with the rendered text (Right). 29 | Still to do: right-to-left sync. And much more. 30 | 31 | ## Running the examples 32 | 33 | ```bash 34 | npm install 35 | 36 | npm start 37 | ``` 38 | 39 | 40 | ## Embedding the Editor 41 | 42 | - Use the `demo-simple` and `demo` apps of this repo as models, or consult 43 | the [Markdown Example Code](https://github.com/jxxcarlson/elm-markdown/tree/master/app-demo-fancy) 44 | - In order to implement external copy-paste (ctrl-shift V), 45 | the [Demo](https://jxxcarlson.github.io/app/editor/index.html) and 46 | [Markdown Example](https://markdown.minilatex.app/) apps import a module `Outside` into `Main`. This module 47 | uses ports and references `outside.js` in `index.html` 48 | 49 | 50 | ## Plans 51 | 52 | - I would very much like this to be a community project; it is a tool that many of us can use to good end. I've posted some issues on the repo, and welcome comments, pull requests, and more issues. 53 | 54 | 55 | - I may post a Road Map later, but [Sydney Nemzer's README](https://github.com/SidneyNemzer/elm-text-editor/blob/master/README.md) is an excellent place to begin. 56 | 57 | 58 | ## Credits 59 | 60 | I would like to thank Folkert de Vries for help 61 | improving the performance of the editor. 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/BufferHelper.elm: -------------------------------------------------------------------------------- 1 | module BufferHelper exposing (Direction(..), Group(..), charsAround, indexFromPosition, isNonWordChar, isWhitespace, isWordChar, slice, stringCharAt, tuple3CharsPred, tuple3MapAll) 2 | 3 | import Array exposing (Array) 4 | import List.Extra 5 | import Maybe.Extra 6 | import Position exposing (Position) 7 | import String.Extra 8 | import Util.Array 9 | 10 | 11 | stringCharAt : Int -> String -> Maybe Char 12 | stringCharAt index string = 13 | String.slice index (index + 1) string 14 | |> String.uncons 15 | |> Maybe.map Tuple.first 16 | 17 | 18 | charsAround : Int -> String -> ( Maybe Char, Maybe Char, Maybe Char ) 19 | charsAround index string = 20 | ( stringCharAt (index - 1) string 21 | , stringCharAt index string 22 | , stringCharAt (index + 1) string 23 | ) 24 | 25 | 26 | tuple3MapAll : (a -> b) -> ( a, a, a ) -> ( b, b, b ) 27 | tuple3MapAll fn ( a1, a2, a3 ) = 28 | ( fn a1, fn a2, fn a3 ) 29 | 30 | 31 | tuple3CharsPred : 32 | (Char -> Bool) 33 | -> ( Maybe Char, Maybe Char, Maybe Char ) 34 | -> ( Bool, Bool, Bool ) 35 | tuple3CharsPred pred = 36 | tuple3MapAll (Maybe.map pred >> Maybe.withDefault False) 37 | 38 | 39 | {-| Internal function for getting the index of the position in a string 40 | 41 | indexFromPosition "reddish\ngreen" (Position 0 2) 42 | --> Just 2 43 | 44 | indexFromPosition "reddish\ngreen" (Position 1 2) 45 | --> Just 10 46 | 47 | -} 48 | indexFromPosition : String -> Position -> Maybe Int 49 | indexFromPosition str position = 50 | -- Doesn't validate columns, only lines 51 | if position.line == 0 then 52 | Just position.column 53 | 54 | else 55 | String.indexes "\n" str 56 | |> List.Extra.getAt (position.line - 1) 57 | |> Maybe.map (\line -> line + position.column + 1) 58 | 59 | 60 | slice : Position -> Position -> String -> Maybe String 61 | slice pos1 pos2 str = 62 | let 63 | index1 = 64 | indexFromPosition str pos1 65 | 66 | index2 = 67 | indexFromPosition str pos2 68 | in 69 | case ( index1, index2 ) of 70 | ( Just i, Just j ) -> 71 | String.slice i j str |> Just 72 | 73 | ( _, _ ) -> 74 | Nothing 75 | 76 | 77 | 78 | -- GROUPING 79 | 80 | 81 | isWhitespace : Char -> Bool 82 | isWhitespace = 83 | String.fromChar >> String.trim >> (==) "" 84 | 85 | 86 | isNonWordChar : Char -> Bool 87 | isNonWordChar = 88 | String.fromChar >> (\a -> String.contains a "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-…") 89 | 90 | 91 | isWordChar : Char -> Bool 92 | isWordChar char = 93 | not (isNonWordChar char) && not (isWhitespace char) 94 | 95 | 96 | type Group 97 | = None 98 | | Word 99 | | NonWord 100 | 101 | 102 | type Direction 103 | = Forward 104 | | Backward 105 | -------------------------------------------------------------------------------- /src/Editor/Widget.elm: -------------------------------------------------------------------------------- 1 | module Editor.Widget exposing 2 | ( columnButton 3 | , columnButtonStyle 4 | , headingStyle 5 | , lightColumnButton 6 | , lightRowButton 7 | , rowButton 8 | , textField 9 | ) 10 | 11 | import Editor.Style as Style 12 | import Html exposing (Attribute, Html, button, div, input, text) 13 | import Html.Attributes exposing (placeholder, style, type_) 14 | import Html.Events exposing (onClick, onInput) 15 | 16 | 17 | columnButtonStyle = 18 | [ style "margin-top" "10px" 19 | , style "font-size" "12px" 20 | , style "border" "none" 21 | , style "margin-right" "8px" 22 | ] 23 | 24 | 25 | headingStyle = 26 | [ style "font-size" "14px" 27 | , style "margin-right" "12px" 28 | , style "float" "left" 29 | ] 30 | 31 | 32 | rowButtonStyle = 33 | [ style "font-size" "12px" 34 | , style "border" "none" 35 | , style "margin-right" "8px" 36 | , style "float" "left" 37 | ] 38 | 39 | 40 | buttonLabelStyle width = 41 | [ style "font-size" "12px" 42 | , style "background-color" Style.darkGray 43 | , style "color" "#eee" 44 | , style "width" (String.fromInt width ++ "px") 45 | , style "height" "24px" 46 | , style "border" "none" 47 | , style "text-align" "left" 48 | ] 49 | 50 | 51 | lightButtonLabelStyle width = 52 | [ style "font-size" "12px" 53 | , style "color" "#444" 54 | , style "width" (String.fromInt width ++ "px") 55 | , style "height" "24px" 56 | , style "border" "none" 57 | , style "text-align" "left" 58 | ] 59 | 60 | 61 | rowButtonLabelStyle width = 62 | [ style "font-size" "12px" 63 | , style "background-color" Style.darkGray 64 | , style "color" "#eee" 65 | , style "width" (String.fromInt width ++ "px") 66 | , style "height" "24px" 67 | , style "border" "none" 68 | ] 69 | 70 | 71 | columnButton width msg str attr = 72 | div (columnButtonStyle ++ attr) 73 | [ button ([ onClick msg ] ++ buttonLabelStyle width) [ text str ] ] 74 | 75 | 76 | lightColumnButton width msg str attr = 77 | div (columnButtonStyle ++ attr) 78 | [ button ([ onClick msg ] ++ lightButtonLabelStyle width) [ text str ] ] 79 | 80 | 81 | lightRowButton width msg str attr = 82 | div (rowButtonStyle ++ attr) 83 | [ button ([ onClick msg ] ++ lightButtonLabelStyle width) [ text str ] ] 84 | 85 | 86 | rowButton width msg str attr = 87 | div (rowButtonStyle ++ attr) 88 | [ button ([ onClick msg ] ++ rowButtonLabelStyle width) [ text str ] ] 89 | 90 | 91 | textField width msg str attr innerAttr = 92 | div ([ style "margin-bottom" "10px" ] ++ attr) 93 | [ input 94 | ([ style "height" "18px" 95 | , style "width" (String.fromInt width ++ "px") 96 | , type_ "text" 97 | , placeholder str 98 | , style "margin-right" "8px" 99 | , onInput msg 100 | ] 101 | ++ innerAttr 102 | ) 103 | [] 104 | ] 105 | -------------------------------------------------------------------------------- /testSpares/Tests/CursorRight.elm: -------------------------------------------------------------------------------- 1 | module Tests.CursorRight exposing (advance) 2 | 3 | import ArchitectureTest exposing (..) 4 | import Buffer 5 | import Editor 6 | import Expect exposing (Expectation) 7 | import Fuzz exposing (Fuzzer) 8 | import Test exposing (..) 9 | import Tests.Common exposing (..) 10 | 11 | 12 | advance : Test 13 | advance = 14 | msgTest "CursorRight advances the cursor" app cursorRight <| 15 | \initialModel _ finalModel -> 16 | let 17 | initialCursor = 18 | Editor.getCursor initialModel 19 | 20 | finalCursor = 21 | Editor.getCursor finalModel 22 | 23 | ok = 24 | case ( finalCursor.line - initialCursor.line, finalCursor.column - initialCursor.column, finalCursor.column ) of 25 | ( 0, 1, _ ) -> 26 | -- advances cursor by 1 but does not change line 27 | True 28 | 29 | ( 1, _, 0 ) -> 30 | -- moves to beginning of next line (WEAK) 31 | True 32 | 33 | ( 0, _, _ ) -> 34 | False 35 | 36 | _ -> 37 | False 38 | in 39 | ok 40 | |> Expect.equal True 41 | 42 | 43 | 44 | -- 45 | --doesNotAdvanceBeyondEndOfLine : Test 46 | --doesNotAdvanceBeyondEndOfLine = 47 | -- msgTest "CursorRight doesn't advance the cursor beyond the end of the line " app cursorRight <| 48 | -- \initialModel _ finalModel -> 49 | -- let 50 | -- initialCursor = 51 | -- Editor.getCursor initialModel 52 | -- 53 | -- finalCursor = 54 | -- Editor.getCursor finalModel 55 | -- 56 | -- initialLineLength = 57 | -- Buffer.lineEnd initialCursor.line (Editor.getBuffer initialModel) 58 | -- 59 | -- cursorWasAtEndOfLine = 60 | -- -- ask if the initial cursor was at the end of the line 61 | -- case Maybe.map2 (-) initialLineLength (Just initialCursor.column) of 62 | -- Just 0 -> 63 | -- True 64 | -- 65 | -- _ -> 66 | -- False 67 | -- 68 | -- ok = 69 | -- case ( finalCursor.line - initialCursor.line, finalCursor.column - initialCursor.column, cursorWasAtEndOfLine ) of 70 | -- ( 0, 1, _ ) -> 71 | -- -- advances cursor by 1 but does not change line 72 | -- True 73 | -- 74 | -- ( 1, _, True ) -> 75 | -- -- moves to beginning of next line (WEAK) 76 | -- True 77 | -- 78 | -- ( 0, _, _ ) -> 79 | -- False 80 | -- 81 | -- _ -> 82 | -- False 83 | -- in 84 | -- ok 85 | -- |> Expect.equal True 86 | -------------------------------------------------------------------------------- /src/Editor/Search.elm: -------------------------------------------------------------------------------- 1 | module Editor.Search exposing (search) 2 | 3 | import Buffer exposing (Buffer, lines) 4 | import Position exposing (Position) 5 | 6 | 7 | {-| Return a list of pairs (k, s), where s 8 | -} 9 | 10 | 11 | 12 | -- searchHits : String -> String -> List (Position, Position) 13 | 14 | 15 | search : String -> Buffer -> List ( Position, Position ) 16 | search key buffer = 17 | searchHits key (lines buffer) 18 | 19 | 20 | {-| 21 | 22 | searchHits "AB" ["about this, we know", "that Babs is the best in the lab", "a stitch in time saves nine"] 23 | --> [({ column = 0, line = 0 },{ column = 5, line = 0 }),({ column = 29, line = 1 },{ column = 32, line = 1 })] 24 | 25 | -} 26 | 27 | 28 | 29 | -- searchHits : String -> List String -> List ( Position, Position ) 30 | 31 | 32 | searchHits key lines_ = 33 | let 34 | width = 35 | String.length key 36 | 37 | expand : ( Int, List Int ) -> List ( Int, Int ) 38 | expand ( i, list ) = 39 | list |> List.map (\item -> ( i, item )) 40 | 41 | makePositions : ( Int, Int ) -> ( Position, Position ) 42 | makePositions ( line, column ) = 43 | ( Position line column, Position line (column + width) ) 44 | in 45 | List.indexedMap (\i line -> ( i, String.indexes key line )) lines_ 46 | |> List.map expand 47 | |> List.concat 48 | |> List.map makePositions 49 | 50 | 51 | 52 | -- 53 | -- 54 | --{-| 55 | -- 56 | -- positions (5, "This is about our lab.", ["about", "lab"]) 57 | -- --> [({ column = 8, line = 5 },{ column = 13, line = 5 }),({ column = 18, line = 5 },{ column = 21, line = 5 })] 58 | -- 59 | ---} 60 | --positions : ( Int, String, List String ) -> List ( Position, Position ) 61 | --positions ( line, source, hits ) = 62 | -- List.map (\hit -> stringIndices hit source) hits 63 | -- |> List.concat 64 | -- |> List.map (\( start, end ) -> ( Position line start, Position line end )) 65 | -- 66 | -- 67 | --{-| 68 | -- 69 | -- stringIndices "ab" "This is about our lab." 70 | -- --> [(8,10),(19,21)] : List ( Int, Int ) 71 | -- 72 | ---} 73 | --stringIndices : String -> String -> List ( Int, Int ) 74 | --stringIndices key source = 75 | -- let 76 | -- n = 77 | -- String.length key 78 | -- in 79 | -- String.indices key source 80 | -- |> List.map (\i -> ( i, i + n )) 81 | -- 82 | -- 83 | --{-| 84 | -- 85 | -- matches "ab" "abc, hoorAy, yada, Blab about it" 86 | -- ["abc,","blab","about"] : List String 87 | -- 88 | ---} 89 | --matches : String -> String -> List Int 90 | --matches key str = 91 | -- str 92 | -- |> String.words 93 | -- |> List.filter (\word -> key == word) 94 | -- 95 | -- 96 | --{-| 97 | -- 98 | -- indexedFilterMap (\i x -> i >= 1 && i <= 3) [0,1,2,3,4,5,6] 99 | -- --> [(1,1),(2,2),(3,3)] 100 | -- 101 | ---} 102 | --indexedFilterMap : (Int -> a -> Bool) -> List a -> List ( Int, a ) 103 | --indexedFilterMap filter list = 104 | -- list 105 | -- |> List.indexedMap (\k item -> ( k, item )) 106 | -- |> List.filter (\( i, item ) -> filter i item) 107 | -------------------------------------------------------------------------------- /src/Window.elm: -------------------------------------------------------------------------------- 1 | module Window exposing 2 | ( Window 3 | , getOffset 4 | , identity 5 | , identity_ 6 | , scroll 7 | , scrollToIncludeCursor 8 | , select 9 | , shift 10 | , shiftPosition 11 | , shiftPosition_ 12 | , shiftPosition__ 13 | ) 14 | 15 | import Position exposing (Position) 16 | 17 | 18 | type alias Window = 19 | { first : Int, last : Int } 20 | 21 | 22 | select : Window -> List String -> List String 23 | select window strings = 24 | strings 25 | |> indexedFilterMap (\i x -> i >= window.first && i <= window.last) 26 | 27 | 28 | {-| 29 | 30 | indexedFilterMap (\i x -> i >= 1 && i <= 3) [0,1,2,3,4,5,6] 31 | --> [1,2,3] : List number 32 | 33 | -} 34 | indexedFilterMap : (Int -> a -> Bool) -> List a -> List a 35 | indexedFilterMap filter list = 36 | list 37 | |> List.indexedMap (\k item -> ( k, item )) 38 | |> List.filter (\( i, item ) -> filter i item) 39 | |> List.map Tuple.second 40 | 41 | 42 | {-| 43 | 44 | Offset is <= 0 45 | 46 | -} 47 | getOffset : Window -> Int -> Int 48 | getOffset window lineNumber_ = 49 | min (window.last - window.first - lineNumber_) 0 50 | 51 | 52 | shiftPosition : Window -> Int -> Int -> Position 53 | shiftPosition window line column = 54 | { line = line + window.first, column = column } 55 | 56 | 57 | shiftPosition_ : Window -> Position -> Position 58 | shiftPosition_ window pos = 59 | { line = pos.line + window.first, column = pos.column } 60 | 61 | 62 | shiftPosition__ : Window -> Position -> Position 63 | shiftPosition__ window pos = 64 | let 65 | l = 66 | pos.line - window.first 67 | in 68 | if l < 0 then 69 | pos 70 | 71 | else 72 | { line = pos.line - window.first, column = pos.column } 73 | 74 | 75 | identity : Window -> Int -> Int -> Position 76 | identity window line column = 77 | { line = line, column = column } 78 | 79 | 80 | identity_ : Window -> Position -> Position 81 | identity_ window pos = 82 | pos 83 | 84 | 85 | shift : Int -> Window -> Window 86 | shift k w = 87 | { w | first = w.first + k, last = w.last + k } 88 | 89 | 90 | scroll : Int -> Window -> Window 91 | scroll k window = 92 | let 93 | index = 94 | window.first + k 95 | in 96 | case ( index < 0, index >= window.first && index <= window.last ) of 97 | ( True, _ ) -> 98 | window 99 | 100 | ( False, True ) -> 101 | window 102 | 103 | ( False, False ) -> 104 | { window | first = window.first + k, last = window.last + k } 105 | 106 | 107 | scrollToIncludeCursor : Position -> Window -> Window 108 | scrollToIncludeCursor cursor window = 109 | let 110 | line = 111 | cursor.line 112 | 113 | offset = 114 | if line >= window.last then 115 | line - window.last 116 | 117 | else if line <= window.first then 118 | line - window.first 119 | 120 | else 121 | 0 122 | in 123 | { window | first = window.first + offset, last = window.last + offset } 124 | -------------------------------------------------------------------------------- /tests/Buffer2Test.elm: -------------------------------------------------------------------------------- 1 | module Buffer2Test exposing (suite) 2 | 3 | import Array 4 | import Array.Util 5 | import Buffer2 as Buffer exposing (Buffer(..)) 6 | import Expect exposing (Expectation) 7 | import Fuzz exposing (Fuzzer, int, list, string) 8 | import Position exposing (Position) 9 | import Test exposing (..) 10 | 11 | 12 | suite : Test 13 | suite = 14 | describe "Buffer" 15 | [ describe "init" 16 | [ test "Buffer.init creates a buffer is properly initialized" <| 17 | \_ -> 18 | let 19 | buffer = 20 | Buffer.init "one\ntwo" 21 | in 22 | Expect.equal buffer (Buffer { content = "one\ntwo", lines = Array.fromList [ "one", "two" ] }) 23 | , test "Buffer created by init is valid" <| 24 | \_ -> 25 | Buffer.init "one\ntwo" 26 | |> Buffer.valid 27 | |> Expect.equal True 28 | , test "Replace a string in the buffer" <| 29 | \_ -> 30 | Buffer.init "One\ntwo\nthree\nfour" 31 | |> Buffer.replace (Position 1 0) (Position 1 3) "TWO" 32 | |> Expect.equal (Buffer { content = "One\nTWO\nthree\nfour", lines = Array.fromList [ "One", "TWO", "three", "four" ] }) 33 | ] 34 | , describe "Array.Util" 35 | [ test "insert (1)" <| 36 | \_ -> 37 | Array.fromList [ "aaa", "bbb" ] 38 | |> Array.Util.insert (Position 0 1) "X" 39 | |> Expect.equal (Array.fromList [ "aXaa", "bbb" ]) 40 | , test "split" <| 41 | \_ -> 42 | Array.fromList [ "aaa", "xyz", "ccc" ] 43 | |> Array.Util.split (Position 1 1) 44 | |> Expect.equal (Array.fromList [ "aaa", "x", "yz", "xyz" ]) 45 | , test "cut" <| 46 | \_ -> 47 | let 48 | result = 49 | { before = Array.fromList [ "abcde", "f" ] 50 | , middle = Array.fromList [ "ghij", "k" ] 51 | , after = Array.fromList [ "lmn", "opqr" ] 52 | } 53 | in 54 | Array.fromList [ "abcde", "fghij", "klmn", "opqr" ] 55 | |> Array.Util.cut (Position 1 1) (Position 2 1) 56 | |> Expect.equal result 57 | , test "cutOut" <| 58 | \_ -> 59 | Array.fromList [ "abcde", "fghij", "klmn", "opqr" ] 60 | |> Array.Util.cutOut (Position 1 1) (Position 2 1) 61 | |> Expect.equal ( Array.fromList [ "ghij", "k" ], Array.fromList [ "abcde", "f", "lmn", "opqr" ] ) 62 | , test "replace" <| 63 | \_ -> 64 | Array.fromList [ "abcde", "fghij", "klmn", "opqr" ] 65 | |> Array.Util.replace (Position 1 1) (Position 2 1) "UVW" 66 | |> Expect.equal (Array.fromList [ "abcde", "f", "UVW", "lmn", "opqr" ]) 67 | ] 68 | ] 69 | -------------------------------------------------------------------------------- /src/Editor/Styles.elm: -------------------------------------------------------------------------------- 1 | module Editor.Styles exposing (editorStyles) 2 | 3 | import Html exposing (Html, text) 4 | import String.Interpolate exposing (interpolate) 5 | 6 | 7 | style : List (Html.Attribute msg) -> List (Html msg) -> Html msg 8 | style = 9 | Html.node "style" 10 | 11 | 12 | editorStyles : StyleConfig -> Html msg 13 | editorStyles styleConfig = 14 | style [] [ text (styleText styleConfig) ] 15 | 16 | 17 | type alias StyleConfig = 18 | { editorWidth : Float 19 | , editorHeight : Float 20 | , lineHeight : Float 21 | , fontProportion : Float 22 | } 23 | 24 | 25 | type alias StyleParams = 26 | { editorWidth : String 27 | , editorHeight : String 28 | , lineHeight : String 29 | , fontSize : String 30 | } 31 | 32 | 33 | getStyleParams : StyleConfig -> StyleParams 34 | getStyleParams c = 35 | { editorWidth = String.fromFloat c.editorWidth 36 | , editorHeight = String.fromFloat c.editorHeight 37 | , lineHeight = String.fromFloat c.lineHeight 38 | , fontSize = String.fromFloat (c.fontProportion * c.lineHeight) 39 | } 40 | 41 | 42 | styleText : StyleConfig -> String 43 | styleText styleConfig = 44 | let 45 | s = 46 | getStyleParams styleConfig 47 | in 48 | interpolate styleTemplate 49 | [ s.editorWidth 50 | , s.fontSize 51 | , s.lineHeight 52 | , s.editorHeight 53 | ] 54 | 55 | 56 | styleTemplate : String 57 | styleTemplate = 58 | """ 59 | 60 | body { font-size: {1}px; 61 | line-height: {2}px;} 62 | 63 | .elm-editor-container { 64 | font-family: monospace; 65 | width: {0}px; 66 | user-select: none; 67 | -webkit-user-select: none; 68 | display: flex; 69 | // overflow-x : scroll; 70 | // overflow-y : scroll; 71 | // height: {3}px; 72 | } 73 | 74 | .elm-editor-container:focus { 75 | outline: none; 76 | // background-color : lightblue; 77 | } 78 | 79 | .elm-editor-gutter { 80 | display: flex; 81 | flex-direction: column; 82 | flex-shrink: 0; 83 | } 84 | 85 | .elm-editor-lines { 86 | flex-grow: 1; 87 | } 88 | 89 | .elm-editor-line-number { 90 | display: inline-block; 91 | width: 35px; 92 | padding-right: 5px; 93 | text-align: right; 94 | background-color: lightgray; 95 | cursor: default; 96 | } 97 | 98 | .elm-editor-line { 99 | cursor: text; 100 | } 101 | 102 | .elm-editor-line__gutter-padding { 103 | width: 5px; 104 | } 105 | 106 | .elm-editor-line__character--has-cursor { 107 | position: relative; 108 | } 109 | 110 | .elm-editor-line__character--selected { 111 | background-color: #8d9ffe; 112 | color: white; 113 | } 114 | 115 | .elm-editor-cursor { 116 | position: absolute; 117 | border-left: 16px solid #990000; 118 | opacity: 0.2; 119 | left: 0; 120 | height: 100%; 121 | } 122 | 123 | .elm-editor-container:focus .elm-editor-cursor { 124 | animation: 1s blink step-start infinite; 125 | border-left: 4px solid #333333; 126 | } 127 | 128 | @keyframes blink { 129 | from, to { 130 | opacity: 0; 131 | } 132 | 50% { 133 | opacity: 1;s 134 | } 135 | } 136 | 137 | 138 | body { 139 | font-family: sans-serif; 140 | 141 | } 142 | 143 | .center-column { 144 | display: flex; 145 | flex-direction: column; 146 | align-items: center; 147 | background-color: lightblue; //: #eeeeee; 148 | } 149 | 150 | #editor-container { 151 | text-align: left; 152 | } 153 | 154 | 155 | 156 | """ 157 | -------------------------------------------------------------------------------- /tests/BufferComparison.elm: -------------------------------------------------------------------------------- 1 | module BufferComparison exposing (suite) 2 | 3 | import Array 4 | import Array.Util 5 | import Buffer as Buffer1 6 | import Buffer2 7 | import Expect exposing (Expectation) 8 | import Fuzz exposing (Fuzzer, int, list, string) 9 | import Position exposing (Position) 10 | import Test exposing (..) 11 | 12 | 13 | suite : Test 14 | suite = 15 | describe "Compare Buffer implementations" 16 | [ describe "Basic operations" 17 | [ test "init" <| 18 | \_ -> 19 | let 20 | str = 21 | "one\ntwo" 22 | 23 | buffer1 = 24 | Buffer1.init str 25 | |> Buffer1.toString 26 | in 27 | str 28 | |> Buffer2.init 29 | |> Buffer2.toString 30 | |> Expect.equal buffer1 31 | , test "insert one character" <| 32 | \_ -> 33 | let 34 | str = 35 | "aaa\nbbb" 36 | 37 | buffer1 = 38 | str 39 | |> Buffer1.init 40 | |> Buffer1.insert (Position 0 1) "X" 41 | |> Buffer1.toString 42 | 43 | buffer2 = 44 | str 45 | |> Buffer2.init 46 | |> Buffer2.insert (Position 0 1) "X" 47 | |> Buffer2.toString 48 | in 49 | buffer1 50 | |> Expect.equal buffer2 51 | , test "insertion of one character with Buffer2 is valid " <| 52 | \_ -> 53 | let 54 | str = 55 | "aaa\nbbb" 56 | 57 | buffer2 = 58 | str 59 | |> Buffer2.init 60 | |> Buffer2.insert (Position 0 1) "X" 61 | in 62 | Buffer2.valid buffer2 63 | |> Expect.equal True 64 | , test "insert newline" <| 65 | \_ -> 66 | let 67 | str = 68 | "aaa\nbbb" 69 | 70 | buffer1 = 71 | str 72 | |> Buffer1.init 73 | |> Buffer1.insert (Position 0 1) "\n" 74 | |> Buffer1.toString 75 | |> Debug.log "1" 76 | 77 | buffer2 = 78 | str 79 | |> Buffer2.init 80 | |> Buffer2.insert (Position 0 1) "\n" 81 | |> Buffer2.toString 82 | |> Debug.log "2" 83 | in 84 | buffer1 85 | |> Expect.equal buffer2 86 | , test "insertion of newline with Buffer2 is valid " <| 87 | \_ -> 88 | let 89 | str = 90 | "aaa\nbbb" 91 | 92 | buffer2 = 93 | str 94 | |> Buffer2.init 95 | |> Buffer2.insert (Position 0 1) "\n" 96 | in 97 | Buffer2.valid buffer2 98 | |> Expect.equal True 99 | ] 100 | ] 101 | -------------------------------------------------------------------------------- /demo/public/assets/custom-element-config.js: -------------------------------------------------------------------------------- 1 | MathJax = { 2 | tex: {inlineMath: [['$', '$'], ['\\(', '\\)']]}, 3 | options: { 4 | skipHtmlTags: {'[+]': ['math-text']} 5 | }, 6 | startup: { 7 | ready: () => { 8 | // 9 | // Get some MathJax objects from the MathJax global 10 | // 11 | // (Ideally, you would turn this into a custom component, and 12 | // then these could be handled by normal imports, but this is 13 | // just an example and so we use an expedient method of 14 | // accessing these for now.) 15 | // 16 | const mathjax = MathJax._.mathjax.mathjax; 17 | const HTMLAdaptor = MathJax._.adaptors.HTMLAdaptor.HTMLAdaptor; 18 | const HTMLHandler = MathJax._.handlers.html.HTMLHandler.HTMLHandler; 19 | const AbstractHandler = MathJax._.core.Handler.AbstractHandler.prototype; 20 | const startup = MathJax.startup; 21 | 22 | // 23 | // Extend HTMLAdaptor to handle shadowDOM as the document 24 | // 25 | class ShadowAdaptor extends HTMLAdaptor { 26 | create(kind, ns) { 27 | const document = (this.document.createElement ? this.document : this.window.document); 28 | return (ns ? 29 | document.createElementNS(ns, kind) : 30 | document.createElement(kind)); 31 | } 32 | text(text) { 33 | const document = (this.document.createTextNode ? this.document : this.window.document); 34 | return document.createTextNode(text); 35 | } 36 | head(doc) { 37 | return doc.head || (doc.firstChild || {}).firstChild || doc; 38 | } 39 | body(doc) { 40 | return doc.body || (doc.firstChild || {}).lastChild || doc; 41 | } 42 | root(doc) { 43 | return doc.documentElement || doc.firstChild || doc; 44 | } 45 | } 46 | 47 | // 48 | // Extend HTMLHandler to handle shadowDOM as document 49 | // 50 | class ShadowHandler extends HTMLHandler { 51 | create(document, options) { 52 | const adaptor = this.adaptor; 53 | if (typeof(document) === 'string') { 54 | document = adaptor.parse(document, 'text/html'); 55 | } else if ((document instanceof adaptor.window.HTMLElement || 56 | document instanceof adaptor.window.DocumentFragment) && 57 | !(document instanceof window.ShadowRoot)) { 58 | let child = document; 59 | document = adaptor.parse('', 'text/html'); 60 | adaptor.append(adaptor.body(document), child); 61 | } 62 | // 63 | // We can't use super.create() here, since that doesn't 64 | // handle shadowDOM correctly, so call HTMLHandler's parent class 65 | // directly instead. 66 | // 67 | return AbstractHandler.create.call(this, document, options); 68 | } 69 | } 70 | 71 | // 72 | // Register the new handler and adaptor 73 | // 74 | startup.registerConstructor('HTMLHandler', ShadowHandler); 75 | startup.registerConstructor('browserAdaptor', () => new ShadowAdaptor(window)); 76 | 77 | // 78 | // A service function that creates a new MathDocument from the 79 | // shadow root with the configured input and output jax, and then 80 | // renders the document. The MathDocument is returned in case 81 | // you need to rerender the shadowRoot later. 82 | // 83 | MathJax.typesetShadow = function (root) { 84 | const InputJax = startup.getInputJax(); 85 | const OutputJax = startup.getOutputJax(); 86 | const html = mathjax.document(root, {InputJax, OutputJax}); 87 | html.render(); 88 | return html; 89 | } 90 | 91 | // 92 | // Now do the usual startup now that the extensions are in place 93 | // 94 | MathJax.startup.defaultReady(); 95 | } 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /demo/public/assets/style.css: -------------------------------------------------------------------------------- 1 | .flex-column { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .flex-column-space { 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: space-between; 10 | } 11 | 12 | 13 | .flex-row { 14 | display: flex; 15 | flex-direction: row; 16 | } 17 | 18 | .flex-row-text-aligned { 19 | display: flex; 20 | flex-direction: row; 21 | align-items: baseline; 22 | } 23 | 24 | 25 | 26 | 27 | .elmsh-line { 28 | padding : 8px; 29 | line-height: 12px !important; 30 | } 31 | 32 | 33 | .mm-paragraph { 34 | white-space: normal; 35 | } 36 | 37 | .toc-level-0 { margin-left: 0em; font-size : 10pt; line-height: 4pt; font-weight: normal; text-decoration: none; } 38 | .toc-level-1 { margin-left: 1em; font-size : 10pt; line-height: 4pt; font-weight: normal; text-decoration: none; } 39 | .toc-level-2 { margin-left: 2em; font-size : 10pt; line-height: 4pt; font-weight: normal; text-decoration: none; } 40 | .toc-level-3 { margin-left: 3em; font-size : 10pt; line-height: 4pt; font-weight: normal; text-decoration: none; } 41 | 42 | .mm-code {font-family: "Courier New" 43 | , Courier, monospace; padding: 8px; background-color: #f5f5f5; font-size: 11pt; 44 | } 45 | .mm-inline-code {font-family: "Courier New" 46 | , Courier, monospace; padding-left: 5px; padding-right: 5px;background-color: #f5f5f5; font-size: 11pt; 47 | } 48 | 49 | .mm-image {width : 100%; } 50 | .mm-image-left {float: left; width: 40%; margin-right: 12px;} 51 | .mm-image-right {float: right; width: 40%; margin-left: 4px;} 52 | .mm-image-center { 53 | display: block; 54 | margin-left: auto; 55 | margin-right: auto; 56 | width: 60%; 57 | } 58 | 59 | .mm-h1 {font-size: 24px;} 60 | .mm-h2 {font-size: 20px;} 61 | .mm-h3 {font-size: 16px} 62 | .mm-h4 {font-size: 12px;} 63 | .mm-h5 {font-size: 12px;} 64 | 65 | 66 | 67 | .highlight { 68 | background-color: #8d9ffe; 69 | } 70 | 71 | .mm-block-0 { 72 | } 73 | 74 | .mm-block-1 { 75 | margin-left: 16px; 76 | } 77 | 78 | .mm-block-2 { 79 | margin-left: 32px; 80 | } 81 | 82 | 83 | .mm-block-3 { 84 | margin-left: 48px; 85 | } 86 | 87 | .mm-block-4 { 88 | margin-left: 64px; 89 | } 90 | 91 | 92 | .mm-olist-item { 93 | list-style: none; 94 | margin-bottom: 8px; 95 | padding-left: 18px; 96 | text-indent: -18px; 97 | } 98 | 99 | .mm-olist-item.mm-block-0 { 100 | margin-left: 12px; 101 | } 102 | 103 | .mm-olist-item.mm-block-1 { 104 | margin-left: 12px; 105 | } 106 | 107 | 108 | .mm-ulist-item { 109 | list-style: none; 110 | margin-bottom: 8px; 111 | padding-left: 18px; 112 | text-indent: -12px; 113 | white-space: normal; 114 | } 115 | 116 | .mm-ulist-item.mm-block-0 { 117 | margin-left: 8px; 118 | } 119 | 120 | .mm-quotation { 121 | margin-top: 18px; 122 | margin-right: 36px; 123 | margin-bottom: 18px; 124 | white-space: normal; 125 | } 126 | .mm-poetry { 127 | margin-left: 24px; 128 | margin-top: 18px; 129 | margin-right: 36px; 130 | margin-bottom: 18px; 131 | white-space: nowrap; 132 | } 133 | .mm-closed-block { 134 | margin-bottom; 12px; 135 | width: inherit; 136 | white-space: normal; 137 | } 138 | .mm-closed-block .mm-inlinelist { 139 | margin-bottom: 12px; 140 | line-height: 18px; 141 | width: inherit; 142 | } 143 | .mm-poetry .mm-inlinelist .mm-inlinelist { 144 | margin-bottom: 4px; 145 | width: inherit; 146 | } 147 | .mm-error-message { 148 | color: red; margin-bottom: 12px; 149 | } 150 | .mm-code { 151 | font: 16px courier; background-color: #eee; padding: 8px; 152 | } 153 | 154 | .mm-strike-through { 155 | text-decoration: line-through; 156 | } 157 | 158 | 159 | .mm-table-cell { 160 | padding: 4px; 161 | margin-right : 3px; 162 | margin-bottom: 6px; 163 | background-color: #eef; 164 | } 165 | 166 | 167 | .mm-paragraph.mm-block-2 { 168 | margin: 0; 169 | } 170 | 171 | 172 | .mm-table-row { 173 | display: block; 174 | margin-left: 40px; 175 | height: 25px; 176 | } 177 | 178 | p.mm-table-row { 179 | display: inline-block; 180 | } 181 | 182 | .mm-table-cell { 183 | display: inline-block; 184 | width: 80px; 185 | } 186 | 187 | p.mm-table-cell { 188 | display: inline-block; 189 | } 190 | 191 | 192 | .mm-verbatim { 193 | font: 16px courier; background-color: #eee; padding: 8px; 194 | } 195 | -------------------------------------------------------------------------------- /docs/Embedding.md: -------------------------------------------------------------------------------- 1 | # Editor Installation 2 | 3 | To install the Editor in app, use the outline below, 4 | consulting the code in `./demo` 5 | 6 | ## Installations 7 | 8 | 9 | ```bash 10 | elm install lukewestby/elm-string-interpolate 11 | elm install carwow/elm-slider 12 | elm install lovasoa/elm-rolling-list 13 | elm install elm-community/string-extra 14 | elm install elm-community/maybe-extra 15 | elm install elm-community/list-extra 16 | elm install folkertdev/elm-paragraph 17 | ``` 18 | 19 | ## Imports 20 | 21 | ``` 22 | import Editor exposing (EditorConfig, Editor, EditorMsg) 23 | import Editor.Config exposing (WrapOption(..)) 24 | import Editor.Strings 25 | import Editor.Update -- for external copy-paste if needed 26 | import SingleSlider as Slider 27 | 28 | ``` 29 | 30 | ## Msg 31 | 32 | ```elm 33 | type Msg 34 | = EditorMsg EditorMsg 35 | | SliderMsg Slider.Msg 36 | | Outside Outside.InfoForElm -- if using module Outside 37 | | LogErr String -- if using module Outside 38 | 39 | | ... 40 | ``` 41 | 42 | ## Model 43 | 44 | ```elm 45 | type alias Model = 46 | { editor : Editor 47 | , clipboard : String 48 | , ... 49 | } 50 | ``` 51 | 52 | ## Init 53 | 54 | ```elm 55 | init : () -> ( Model, Cmd Msg ) 56 | init () = 57 | ( { editor = Editor.init config "Some text ..." 58 | , clipboard = "" 59 | } 60 | , Cmd.none 61 | ) 62 | ``` 63 | 64 | 65 | where (for example): 66 | 67 | ```elm 68 | config : EditorConfig Msg 69 | config = 70 | { editorMsg = EditorMsg 71 | , sliderMsg = SliderMsg 72 | , editorStyle = editorStyle 73 | , width = 500 74 | , lines = 30 75 | , lineHeight = 16.0 76 | , showInfoPanel = True 77 | , wrapParams = { maximumWidth = 55, optimalWidth = 50, stringWidth = String.length } 78 | , wrapOption = DontWrap 79 | } 80 | ``` 81 | 82 | ``` 83 | editorStyle : List (Html.Attribute msg) 84 | editorStyle = 85 | [ HA.style "background-color" "#dddddd" 86 | , HA.style "border" "solid 0.5px" 87 | ] 88 | ``` 89 | 90 | 91 | ## Update 92 | 93 | ```elm 94 | update : Msg -> Model -> ( Model, Cmd Msg ) 95 | update msg model = 96 | case msg of 97 | EditorMsg editorMsg -> 98 | let 99 | -- needed for ezxternal copy-paste: 100 | clipBoardCmd = 101 | if editorMsg == Editor.Update.CopyPasteClipboard then 102 | Outside.sendInfo (Outside.AskForClipBoard E.null) 103 | 104 | else 105 | Cmd.none 106 | 107 | ( editor, cmd ) = 108 | Editor.update editorMsg model.editor 109 | in 110 | ( { model | editor = editor } 111 | , Cmd.batch [ clipBoardCmd, Cmd.map EditorMsg cmd ] ) 112 | 113 | SliderMsg sliderMsg -> 114 | let 115 | ( newEditor, cmd ) = 116 | Editor.sliderUpdate sliderMsg model.editor 117 | in 118 | ( { model | editor = newEditor }, cmd |> Cmd.map SliderMsg ) 119 | 120 | -- The below are optional, and used for external copy/pastes 121 | -- See module `Outside` and also `outside.js` and `index.html` for additional 122 | -- information 123 | 124 | Outside infoForElm -> 125 | case infoForElm of 126 | 127 | Outside.GotClipboard clipboard -> 128 | ({model | clipboard = clipboard}, Cmd.none) 129 | 130 | 131 | 132 | Other cases ... 133 | ``` 134 | 135 | ## Subscriptions 136 | 137 | ```elm 138 | subscriptions : Model -> Sub Msg 139 | subscriptions model = 140 | Sub.batch 141 | [ Sub.map SliderMsg <| 142 | Slider.subscriptions (Editor.slider model.editor) 143 | ] 144 | ``` 145 | 146 | ## View 147 | 148 | ```elm 149 | view : Model -> Html Msg 150 | view model = 151 | div [ HA.style "margin" "60px" ] 152 | [ title -- for example 153 | , Editor.embedded config model.editor 154 | , footer model -- for example 155 | ] 156 | ``` 157 | 158 | ## Files 159 | 160 | - Use the `index.html` file in `./demo` as a starting point for your `index.html` 161 | - Copy the files `outside.js` and `Outside.elm` if you are 162 | implementing external copy-paste -------------------------------------------------------------------------------- /src/Buffer2.elm: -------------------------------------------------------------------------------- 1 | module Buffer2 exposing 2 | ( Buffer(..) 3 | , init 4 | , insert 5 | , nearWordChar 6 | , replace 7 | , toString 8 | , valid 9 | ) 10 | 11 | import Array exposing (Array) 12 | import Array.Util 13 | import BufferHelper as BH 14 | import List.Extra 15 | import Maybe.Extra 16 | import Position exposing (Position) 17 | import String.Extra 18 | 19 | 20 | {-| Returns True iff the buffer is valid 21 | 22 | valid (init "One\ntwo\nthree\nfour") 23 | --> True 24 | 25 | bb2 : Buffer 26 | bb2 = Buffer { content = "One\ntwo\nthree\nfour", lines = Array.fromList ["One","two!","three","four"] } 27 | 28 | valid bb2 29 | --> False 30 | 31 | -} 32 | type Buffer 33 | = Buffer { content : String, lines : Array String } 34 | 35 | 36 | {-| Create a new buffer from a string 37 | 38 | import Array 39 | 40 | init "one\ntwo" 41 | --> Buffer { content = "one\ntwo", lines = Array.fromList ["one","two"] } 42 | 43 | -} 44 | init : String -> Buffer 45 | init content = 46 | Buffer { content = content, lines = Array.fromList (String.lines content) } 47 | 48 | 49 | valid : Buffer -> Bool 50 | valid (Buffer data) = 51 | (data.lines |> Array.toList |> String.join "\n") == data.content 52 | 53 | 54 | toString : Buffer -> String 55 | toString (Buffer data) = 56 | data.content 57 | 58 | 59 | {-| Returns true if the Position is at or after a word character. See isWordChar. 60 | 61 | import Position exposing(..) 62 | 63 | nearWordChar (Position 0 0) (init "one\ntwo") 64 | --> True 65 | 66 | nearWordChar (Position 0 10) (init "one\ntwo") 67 | --> False 68 | 69 | -} 70 | nearWordChar : Position -> Buffer -> Bool 71 | nearWordChar position (Buffer data) = 72 | BH.indexFromPosition data.content position 73 | |> Maybe.andThen 74 | (\index -> 75 | let 76 | previousChar = 77 | BH.stringCharAt (index - 1) data.content 78 | 79 | currentChar = 80 | BH.stringCharAt index data.content 81 | in 82 | Maybe.map BH.isWordChar previousChar 83 | |> Maybe.Extra.orElseLazy 84 | (\() -> Maybe.map BH.isWordChar currentChar) 85 | ) 86 | |> Maybe.withDefault False 87 | 88 | 89 | {-| Insert a string into the buffer. 90 | 91 | import Array 92 | 93 | import Position 94 | 95 | bb: Buffer 96 | bb = init "aaa\nbbb" 97 | --> Buffer { content = "aaa\nbbb", lines = Array.fromList ["aaa","bbb"] } 98 | 99 | insert (Position 0 1) "X" bb 100 | --> Buffer { content = "aXaa\nbbb", lines = Array.fromList ["aXaa","bbb"] } 101 | 102 | valid bb2 103 | --> True 104 | 105 | -} 106 | insert : Position -> String -> Buffer -> Buffer 107 | insert position str (Buffer data) = 108 | let 109 | content = 110 | BH.indexFromPosition data.content position 111 | |> Maybe.map (\index -> String.Extra.insertAt str index data.content) 112 | |> Maybe.withDefault data.content 113 | 114 | lines = 115 | Array.Util.insert position str data.lines 116 | in 117 | Buffer { content = content, lines = lines } 118 | 119 | 120 | {-| Replace the string between two positions with a different string. 121 | 122 | import Array 123 | 124 | import Position 125 | 126 | 127 | bb : Buffer 128 | bb = 129 | init "One\ntwo\nthree\nfour" 130 | 131 | 132 | replace (Position 1 0) (Position 1 3) "TWO" bb 133 | --> Buffer { content = "One\nTWO\nthree\nfour", lines = Array.fromList ["One","TWO","three","four"] } 134 | 135 | replace (Position 1 0) (Position 2 3) "TWO\nTHREE" bb 136 | 137 | -} 138 | replace : Position -> Position -> String -> Buffer -> Buffer 139 | replace pos1 pos2 str (Buffer data) = 140 | let 141 | ( start, end ) = 142 | Position.order pos1 pos2 143 | 144 | content : String 145 | content = 146 | Maybe.map2 147 | (\startIndex endIndex -> 148 | String.slice 0 startIndex data.content 149 | ++ str 150 | ++ String.dropLeft endIndex data.content 151 | ) 152 | (BH.indexFromPosition data.content start) 153 | (BH.indexFromPosition data.content end) 154 | |> Maybe.withDefault data.content 155 | in 156 | Buffer { content = content, lines = Array.Util.replace start end str data.lines } 157 | 158 | 159 | 160 | -- HELPER FUNCTIONS THAT DO NOT REFERENCE BUFFER -- 161 | -------------------------------------------------------------------------------- /testSpares/UnitTest.elm: -------------------------------------------------------------------------------- 1 | module UnitTest exposing (suite) 2 | 3 | import Buffer exposing (..) 4 | import Debounce 5 | import Editor exposing (EditorConfig, EditorMsg) 6 | import Editor.Config exposing (WrapOption(..)) 7 | import Editor.Function as F exposing (bufferOf, stateOf) 8 | import Editor.History 9 | import Editor.Model exposing (InternalState) 10 | import Editor.Update exposing (Msg(..)) 11 | import Expect exposing (Expectation) 12 | import Fuzz exposing (Fuzzer, int, list, string) 13 | import Position exposing (Position) 14 | import RollingList 15 | import Test exposing (..) 16 | 17 | 18 | testBuffer = 19 | Buffer "a\nbc" 20 | 21 | 22 | type Msg 23 | = EditorMsg EditorMsg 24 | 25 | 26 | editorConfig : EditorConfig Msg 27 | editorConfig = 28 | { editorMsg = EditorMsg 29 | , width = 450 30 | , height = 544 31 | , lineHeight = 16.0 32 | , showInfoPanel = False 33 | , wrapParams = { maximumWidth = 55, optimalWidth = 50, stringWidth = String.length } 34 | , wrapOption = DontWrap 35 | } 36 | 37 | 38 | state : InternalState 39 | state = 40 | { config = Editor.transformConfig editorConfig 41 | , cursor = Position 0 0 42 | , selection = Nothing 43 | , selectedText = Nothing 44 | , clipboard = "" 45 | , currentLine = Nothing 46 | , dragging = False 47 | , history = Editor.History.empty 48 | , searchTerm = "" 49 | , replacementText = "" 50 | , canReplace = False 51 | , searchResults = RollingList.fromList [] 52 | , showHelp = True 53 | , showInfoPanel = editorConfig.showInfoPanel 54 | , showGoToLinePanel = False 55 | , showSearchPanel = False 56 | , savedBuffer = Buffer.fromString "abc\ndef" 57 | , debounce = Debounce.init 58 | , topLine = 0 59 | , searchHitIndex = 0 60 | } 61 | 62 | 63 | suite : Test 64 | suite = 65 | describe "Editor update" 66 | [ describe "CursorForward" 67 | -- Nest as many descriptions as you like. 68 | [ test "has no effect on a palindrome" <| 69 | \_ -> 70 | let 71 | palindrome = 72 | "hannah" 73 | in 74 | Expect.equal palindrome (String.reverse palindrome) 75 | 76 | -- Expect.equal is designed to be used in pipeline style, like this. 77 | , test "reverses a known string" <| 78 | \_ -> 79 | "ABCDEFG" 80 | |> String.reverse 81 | |> Expect.equal "GFEDCBA" 82 | 83 | -- fuzz runs the test 100 times with randomly-generated inputs! 84 | , fuzz string "restores the original string if you run it again" <| 85 | \randomlyGeneratedString -> 86 | randomlyGeneratedString 87 | |> String.reverse 88 | |> String.reverse 89 | |> Expect.equal randomlyGeneratedString 90 | , test "Cursor forward, inside line" <| 91 | \_ -> 92 | let 93 | buffer = 94 | Buffer.fromString "abc\ndef" 95 | 96 | state1 = 97 | { state | cursor = Position 0 1 } 98 | 99 | state2 = 100 | F.cursorRight state1 buffer |> stateOf 101 | in 102 | Expect.equal state2.cursor (Position 0 2) 103 | , test "Cursor forward, inside line, correct character" <| 104 | \_ -> 105 | let 106 | buffer = 107 | Buffer.fromString "abc\ndef" 108 | 109 | state1 = 110 | { state | cursor = Position 0 1 } 111 | 112 | state2 = 113 | F.cursorRight state1 buffer |> stateOf 114 | in 115 | Expect.equal (String.slice 2 3 (Buffer.toString buffer)) "c" 116 | , test "Cursor forward just before end of line" <| 117 | \_ -> 118 | let 119 | buffer = 120 | Buffer.fromString "abc\ndef" 121 | 122 | state1 = 123 | { state | cursor = Position 0 2 } 124 | 125 | state2 = 126 | F.cursorRight state1 buffer |> stateOf 127 | in 128 | Expect.equal state2.cursor (Position 0 3) 129 | ] 130 | ] 131 | -------------------------------------------------------------------------------- /src/Array/Util.elm: -------------------------------------------------------------------------------- 1 | module Array.Util exposing (Position, cut, cutOut, insert, put, replace, split, splitStringAt) 2 | 3 | import Array exposing (Array) 4 | import List.Extra 5 | import String.Extra 6 | 7 | 8 | type alias Position = 9 | { line : Int, column : Int } 10 | 11 | 12 | type alias StringZipper = 13 | { before : Array String 14 | , middle : Array String 15 | , after : Array String 16 | } 17 | 18 | 19 | {-| 20 | 21 | arrL = Array.fromList ["aaa", "bbb"] 22 | 23 | insert (Position 0 1) "X" arrL 24 | --> Array.fromList ["aXaa","bbb"] 25 | 26 | insert (Position 1 1) "X" arrL 27 | --> Array.fromList ["aaa","bXbb"] 28 | 29 | -} 30 | insert : Position -> String -> Array String -> Array String 31 | insert position str array = 32 | case Array.get position.line array of 33 | Nothing -> 34 | array 35 | 36 | Just line -> 37 | let 38 | newLine = 39 | String.Extra.insertAt str position.column line 40 | in 41 | Array.set position.line newLine array 42 | 43 | 44 | {-| 45 | 46 | arr : Array String 47 | arr = Array.fromList ["aaa","xyz","ccc"] 48 | 49 | split (Position 1 1) arr 50 | Array.fromList ["aaa","x","yz","xyz"] 51 | 52 | -} 53 | split : Position -> Array String -> Array String 54 | split position array = 55 | case Array.get position.line array of 56 | Nothing -> 57 | array 58 | 59 | Just focus -> 60 | let 61 | focusLength = 62 | String.length focus 63 | 64 | before = 65 | Array.slice 0 position.line array 66 | 67 | after = 68 | Array.slice position.line (focusLength - 1) array 69 | 70 | beforeSuffix = 71 | String.slice 0 position.column focus 72 | 73 | afterPrefix = 74 | String.slice position.column focusLength focus 75 | 76 | firstPart = 77 | Array.push beforeSuffix before 78 | 79 | secondPart = 80 | put afterPrefix after 81 | in 82 | Array.append firstPart secondPart 83 | 84 | 85 | splitStringAt : Int -> String -> ( String, String ) 86 | splitStringAt k str = 87 | let 88 | n = 89 | String.length str 90 | in 91 | ( String.slice 0 k str, String.slice k n str ) 92 | 93 | 94 | {-| 95 | 96 | arr = 97 | Array.fromList [ "abcde", "fghij", "klmn", "opqr" ] 98 | 99 | cut (Position 1 1) (Position 2 1) arr 100 | --> { before = Array.fromList ["abcde","f"] 101 | , middle = Array.fromList ["ghij","k"] 102 | , after = Array.fromList ["lmn","opqr"] 103 | } 104 | 105 | -} 106 | cut : Position -> Position -> Array String -> StringZipper 107 | cut pos1 pos2 array = 108 | let 109 | n = 110 | Array.length array 111 | 112 | before_ = 113 | Array.slice 0 pos1.line array 114 | 115 | ( a, b ) = 116 | Array.get pos1.line array |> Maybe.withDefault "" |> splitStringAt pos1.column 117 | 118 | middle_ = 119 | Array.slice (pos1.line + 1) pos2.line array 120 | 121 | m = 122 | Array.length middle_ 123 | 124 | ( c, d ) = 125 | Array.get pos2.line array |> Maybe.withDefault "" |> splitStringAt pos2.column 126 | 127 | after_ = 128 | Array.slice (pos2.line + 1) n array 129 | in 130 | { before = Array.push a before_ 131 | , middle = Array.push c (put b middle_) 132 | , after = put d after_ 133 | } 134 | 135 | 136 | {-| 137 | 138 | arr = 139 | Array.fromList [ "abcde", "fghij", "klmn", "opqr" ] 140 | 141 | cutOut (Position 1 1) (Position 2 1) arr 142 | --> (Array.fromList ["ghij","k"], Array.fromList ["abcde","f","lmn","opqr"]) 143 | 144 | -} 145 | cutOut : Position -> Position -> Array String -> ( Array String, Array String ) 146 | cutOut pos1 pos2 array = 147 | cut pos1 pos2 array 148 | |> (\sz -> ( sz.middle, Array.append sz.before sz.after )) 149 | 150 | 151 | {-| Assume pos1 < pos2 152 | 153 | replace (Position 1 1) (Position 2 1) "UVW" arr 154 | --> Array.fromList ["abcde","f","UVW","lmn","opqr"] 155 | 156 | -} 157 | replace : Position -> Position -> String -> Array String -> Array String 158 | replace pos1 pos2 str array = 159 | case pos1.line == pos2.line of 160 | True -> 161 | case Array.get pos1.line array of 162 | Nothing -> 163 | array 164 | 165 | Just line -> 166 | let 167 | newLine = 168 | String.Extra.replaceSlice str pos1.column pos2.column line 169 | in 170 | Array.set pos1.line newLine array 171 | 172 | False -> 173 | let 174 | sz = 175 | cut pos1 pos2 array 176 | in 177 | Array.append (Array.push str sz.before) sz.after 178 | 179 | 180 | put : String -> Array String -> Array String 181 | put str array = 182 | Array.append (Array.fromList [ str ]) array 183 | -------------------------------------------------------------------------------- /src/Editor/Strings.elm: -------------------------------------------------------------------------------- 1 | module Editor.Strings exposing (help, info) 2 | 3 | {-| Strings for the help system. 4 | 5 | @docs help, info 6 | 7 | -} 8 | 9 | 10 | {-| Help text for the editor 11 | -} 12 | help : String 13 | help = 14 | """ 15 | ------------------------------------------ 16 | Key commands 17 | ------------------------------------------ 18 | 19 | NEW: ctrl-shift-S to sync with external 20 | window if the hosting app implements it. 21 | Still flaky. Aslo: ctrl-shift-C to copy 22 | text to the system keyboard, ctrl-shift-V 23 | to paste text from the system keyboard 24 | to the editor. For now, Chrome only. 25 | 26 | Show help ctrl-h (Toggle) 27 | Show info panel ctrl-shift-i (Toggle) 28 | 29 | Cursor 30 | ------ 31 | 32 | Forward right-arrow 33 | Backwards left-arrow 34 | Start of line option-left-arrow or Home 35 | End of line option-right-arrow or End 36 | 37 | Line Up up-arrow 38 | Line Down down-arrow 39 | 40 | Up many lines option up-arrow 41 | Down many lines option down-arrow 42 | 43 | First line ctrl-option up-arrow 44 | Last line ctrl-option down-arrow 45 | 46 | Go to line ctrl-g (Toggle) 47 | 48 | 49 | Selection 50 | --------- 51 | Select word Double-click 52 | Select line Triple-click 53 | Select group ctrl-d 54 | Select all ctrl-shift-a 55 | 56 | Extend selection shift-arrow 57 | (up | down | left | right) 58 | 59 | Copy selection ctrl-c 60 | Cut selection ctrl-x 61 | Paste selection ctrl-v 62 | 63 | External copy-paste 64 | ------------------- 65 | 66 | - ctrl-shift-C copies selected text to the 67 | system clipboard. 68 | 69 | - ctrl-shift-V copies text from the system 70 | clipboard and pastes the content to the 71 | editor at current cursor. The copied 72 | text remains in the Editor clipboard. 73 | 74 | The pasted text will be wrapped if the 75 | the WrapOption is on. 76 | 77 | This works in Chrome 79 but not Firefox. 78 | In Chrome you have to respond to a permission 79 | dialog each time. I'll see if this can 80 | be reduced to once per session. 81 | 82 | Text 83 | ------------ 84 | 85 | Indent Tab 86 | De-indent shift-Tab 87 | 88 | Wrap selection ctrl-w 89 | Wrap all ctrl-shift-w 90 | Toggle wrapping ctrl-option-w 91 | 92 | Typing ctrl-shift-w at the end 93 | of a paragraph will wrap it. 94 | 95 | Clear all ctrl-option c 96 | 97 | Search 98 | ------ 99 | 100 | Search panel ctrl-s (Toggle) 101 | Replace panel ctrl-r (Toggle) 102 | Next search hit ctrl-. (Think >) 103 | Prev search hit ctrl-. (Think <) 104 | 105 | Undo/Redo 106 | ---------- 107 | 108 | Undo ctrl-z 109 | Redo ctrl-y 110 | 111 | """ 112 | 113 | 114 | {-| Information about the Pure Elm Editor project. 115 | -} 116 | info : String 117 | info = 118 | """This is a first test of how 119 | the editor could be used as a package. 120 | The 'Info' button is a proxy for loading new 121 | content into the editor from an external 122 | source. 123 | 124 | Everything in this window is from `Editor.view`. All 125 | the rest is in `Main`, though of course it uses 126 | functions exported by `Editor`, e.g., the slider. 127 | 128 | The "Reset" button loads the initial text. 129 | The "Gettysburg address" button loads 130 | Abraham Lincoln's speech. It contains three 131 | very long lines which are wrapped by the editor 132 | before you see them. I need to devote more 133 | thought to how best to do text wrapping — 134 | it needs to be an option, for instance. 135 | Am using Folkert de Vries' *elm-paragraph* 136 | library for this. 137 | 138 | NOTES 139 | 140 | 1. At present, all the editor controls 141 | are key commands. Press the "Help" button, 142 | in hte info panel, upper right, for a partial 143 | list of these. Full list coming soon. 144 | 145 | 2. More parameters are now configurable. 146 | For example, one can do this in Main: 147 | 148 | editorState = Editor.init 149 | { defaultConfig | lines = 30 150 | , showInfoPanel = False 151 | } 152 | 153 | to set up the embedded editor with a 30-line 154 | display and the info panel not shown. 155 | 156 | 3. I've tested the editor on a file of 1700 157 | lines and 7700 words. It works fine. For 158 | a file of 17,000 lines and 770,000 words, 159 | moving around the text is extremely fast, 160 | whereas double-clicking to select a word 161 | fails. I don't know why as of this writing. 162 | 163 | 4. In view of (3), I've set the gutter width 164 | to accommodate files of up to 9,999 lines. 165 | 166 | ROADMAP 167 | 168 | There is still a lot to do. 169 | 170 | 171 | 172 | """ 173 | 174 | 175 | external = 176 | """ 177 | External copy-paste 178 | ------------------- 179 | 180 | - ctrl-shift-u copies text from the system 181 | clipboard and pastes the content to the 182 | editor at current cursor. The copied 183 | text remains in the Editor clipboard. 184 | 185 | - ctrl-shift-v pastes text from Editor 186 | clipboard 187 | 188 | The pasted text will be wrapped if the 189 | the WrapOption is on. 190 | 191 | This works in Chrome 79 but not Firefox. 192 | In Chrome you have to respond to a permission 193 | dialog each time. I'll see if this can 194 | be reduced to once per session. 195 | """ 196 | -------------------------------------------------------------------------------- /testSpares/Tests/Common.elm: -------------------------------------------------------------------------------- 1 | module Tests.Common exposing (Msg(..), app, config, cursorDown, cursorLeft, cursorRight, cursorUp, firstLine, initModel, insert, insertBlank, insertOne, lastLine, modelTemplate, modelToString, msgTostring, noOp, oneCharString, removeCharAfter, removeCharBefore, renderVisible, weightedMsgFuzzer) 2 | 3 | import ArchitectureTest exposing (..) 4 | import Editor exposing (..) 5 | import Editor.Config exposing (WrapOption(..)) 6 | import Editor.Update exposing (..) 7 | import Fuzz exposing (Fuzzer, int, list, string) 8 | import String.Interpolate exposing (interpolate) 9 | 10 | 11 | app : TestedApp Editor EditorMsg 12 | app = 13 | { model = ConstantModel initModel 14 | , update = NormalUpdate Editor.update 15 | , msgFuzzer = Fuzz.constant CursorRight -- weightedMsgFuzzer 16 | , msgToString = msgTostring 17 | , modelToString = modelToString 18 | } 19 | 20 | 21 | type Msg 22 | = EditorMsg EditorMsg 23 | 24 | 25 | modelToString : Editor -> String 26 | modelToString editor = 27 | let 28 | pos = 29 | getCursor editor 30 | 31 | line = 32 | pos.line |> String.fromInt 33 | 34 | column = 35 | pos.column |> String.fromInt 36 | 37 | source = 38 | getSource editor 39 | 40 | decoratedSource = 41 | source 42 | |> String.replace " " "°" 43 | |> String.lines 44 | |> List.map (\line_ -> (line_ |> String.length |> String.fromInt) ++ ": " ++ line_) 45 | |> List.indexedMap (\i line_ -> String.fromInt i ++ ", " ++ line_) 46 | |> String.join "\n" 47 | in 48 | interpolate modelTemplate [ line, column, decoratedSource ] 49 | 50 | 51 | renderVisible : String -> String 52 | renderVisible str = 53 | str 54 | |> String.replace " " "°" 55 | |> String.replace "\n" "(NL)" 56 | 57 | 58 | modelTemplate = 59 | "MODEL\ncursor = ({0}, {1})\nbuffer:\n{2}\n" 60 | 61 | 62 | msgTostring : EditorMsg -> String 63 | msgTostring editorMsg = 64 | case editorMsg of 65 | NoOp -> 66 | "NoOp" 67 | 68 | CursorUp -> 69 | "CursorUp" 70 | 71 | CursorDown -> 72 | "CursorDown" 73 | 74 | CursorLeft -> 75 | "CursorLeft" 76 | 77 | CursorRight -> 78 | "CursorRight" 79 | 80 | Insert str -> 81 | "Insert: " ++ renderVisible str 82 | 83 | RemoveCharBefore -> 84 | "RemoveCharBefore" 85 | 86 | RemoveCharAfter -> 87 | "RemoveCharAfter" 88 | 89 | FirstLine -> 90 | "CursorRight" 91 | 92 | LastLine -> 93 | "CursorRight" 94 | 95 | _ -> 96 | "Undefined" 97 | 98 | 99 | initModel : Editor 100 | initModel = 101 | Editor.init config "a" 102 | 103 | 104 | 105 | -- "abc\ndefg\n" 106 | 107 | 108 | config : EditorConfig Msg 109 | config = 110 | { editorMsg = EditorMsg 111 | , width = 450 112 | , height = 544 113 | , lineHeight = 16.0 114 | , showInfoPanel = False 115 | , wrapParams = { maximumWidth = 55, optimalWidth = 50, stringWidth = String.length } 116 | , wrapOption = DontWrap 117 | } 118 | 119 | 120 | weightedMsgFuzzer : Fuzzer EditorMsg 121 | weightedMsgFuzzer = 122 | Fuzz.frequency 123 | [ ( 1, noOp ) 124 | , ( 1, firstLine ) 125 | , ( 1, lastLine ) 126 | , ( 1, cursorUp ) 127 | , ( 1, cursorDown ) 128 | , ( 1, cursorLeft ) 129 | , ( 1, cursorRight ) 130 | , ( 10, insertOne ) 131 | , ( 2, insertBlank ) 132 | , ( 1, removeCharBefore ) 133 | , ( 1, removeCharAfter ) 134 | ] 135 | 136 | 137 | noOp : Fuzzer EditorMsg 138 | noOp = 139 | Fuzz.constant NoOp 140 | 141 | 142 | cursorLeft : Fuzzer EditorMsg 143 | cursorLeft = 144 | Fuzz.constant CursorLeft 145 | 146 | 147 | cursorRight : Fuzzer EditorMsg 148 | cursorRight = 149 | Fuzz.constant CursorRight 150 | 151 | 152 | cursorUp : Fuzzer EditorMsg 153 | cursorUp = 154 | Fuzz.constant CursorUp 155 | 156 | 157 | cursorDown : Fuzzer EditorMsg 158 | cursorDown = 159 | Fuzz.constant CursorDown 160 | 161 | 162 | firstLine : Fuzzer EditorMsg 163 | firstLine = 164 | Fuzz.constant FirstLine 165 | 166 | 167 | lastLine : Fuzzer EditorMsg 168 | lastLine = 169 | Fuzz.constant LastLine 170 | 171 | 172 | insertOne : Fuzzer EditorMsg 173 | insertOne = 174 | Fuzz.string |> Fuzz.map oneCharString |> Fuzz.map Insert 175 | 176 | 177 | oneCharString : String -> String 178 | oneCharString str = 179 | case String.left 1 str of 180 | "" -> 181 | " " 182 | 183 | _ -> 184 | str 185 | 186 | 187 | insert : Fuzzer EditorMsg 188 | insert = 189 | Fuzz.string |> Fuzz.map Insert 190 | 191 | 192 | insertBlank : Fuzzer EditorMsg 193 | insertBlank = 194 | Fuzz.constant " " |> Fuzz.map Insert 195 | 196 | 197 | removeCharBefore : Fuzzer EditorMsg 198 | removeCharBefore = 199 | Fuzz.constant RemoveCharBefore 200 | 201 | 202 | removeCharAfter : Fuzzer EditorMsg 203 | removeCharAfter = 204 | Fuzz.constant RemoveCharAfter 205 | 206 | 207 | 208 | -- 209 | --hover : Fuzzer Msg 210 | --hover = 211 | -- Fuzz.oneOf 212 | -- [ Fuzz.constant NoHover 213 | -- , Fuzz.map HoverLine Fuzz.int 214 | -- , Fuzz.map2 (\line column -> HoverChar { line = line, column = column }) Fuzz.int Fuzz.int 215 | -- ] 216 | -- |> Fuzz.map Hover 217 | -- 218 | -- 219 | --goToHoveredPosition : Fuzzer Msg 220 | --goToHoveredPosition = 221 | -- Fuzz.constant GoToHoveredPosition 222 | -------------------------------------------------------------------------------- /src/Editor/Keymap.elm: -------------------------------------------------------------------------------- 1 | module Editor.Keymap exposing (decoder) 2 | 3 | import Dict exposing (Dict) 4 | import Editor.Update exposing (Msg(..)) 5 | import Json.Decode as Decode exposing (Decoder) 6 | 7 | 8 | type Modifier 9 | = None 10 | | Control 11 | | Option 12 | | Shift 13 | | ControlAndShift 14 | | ControlAndOption 15 | 16 | 17 | type alias Keydown = 18 | { char : Maybe String 19 | , key : String 20 | , modifier : Modifier 21 | } 22 | 23 | 24 | modifier : Bool -> Bool -> Bool -> Modifier 25 | modifier ctrl shift option = 26 | case ( ctrl, shift, option ) of 27 | ( True, True, False ) -> 28 | ControlAndShift 29 | 30 | ( False, True, False ) -> 31 | Shift 32 | 33 | ( True, False, False ) -> 34 | Control 35 | 36 | ( False, False, True ) -> 37 | Option 38 | 39 | ( True, False, True ) -> 40 | ControlAndOption 41 | 42 | ( _, _, _ ) -> 43 | None 44 | 45 | 46 | modifierDecoder : Decoder Modifier 47 | modifierDecoder = 48 | Decode.map3 modifier 49 | (Decode.field "ctrlKey" Decode.bool) 50 | (Decode.field "shiftKey" Decode.bool) 51 | (Decode.field "altKey" Decode.bool) 52 | 53 | 54 | characterDecoder : Decoder (Maybe String) 55 | characterDecoder = 56 | Decode.field "key" Decode.string 57 | |> Decode.map 58 | (\key -> 59 | case String.uncons key of 60 | Just ( char, "" ) -> 61 | Just (String.fromChar char) 62 | 63 | _ -> 64 | Nothing 65 | ) 66 | 67 | 68 | keydownDecoder : Decoder Keydown 69 | keydownDecoder = 70 | Decode.map3 Keydown 71 | characterDecoder 72 | (Decode.field "key" Decode.string) 73 | modifierDecoder 74 | 75 | 76 | decoder : Decoder Msg 77 | decoder = 78 | keydownDecoder |> Decode.andThen keyToMsg 79 | 80 | 81 | type alias Keymap = 82 | Dict String Msg 83 | 84 | 85 | keymaps : 86 | { noModifier : Keymap 87 | , shift : Keymap 88 | , option : Keymap 89 | , control : Keymap 90 | , controlAndShift : Keymap 91 | , controlAndOption : Keymap 92 | } 93 | keymaps = 94 | { noModifier = 95 | Dict.fromList 96 | [ ( "ArrowUp", CursorUp ) 97 | , ( "ArrowDown", CursorDown ) 98 | , ( "ArrowLeft", CursorLeft ) 99 | , ( "ArrowRight", CursorRight ) 100 | , ( "Backspace", RemoveCharBefore ) 101 | , ( "Delete", RemoveCharAfter ) 102 | , ( "Enter", Insert "\n" ) 103 | , ( "Home", CursorToLineStart ) 104 | , ( "End", CursorToLineEnd ) 105 | , ( "Tab", Indent ) 106 | ] 107 | , shift = 108 | Dict.fromList 109 | [ ( "ArrowUp", SelectUp ) 110 | , ( "ArrowDown", SelectDown ) 111 | , ( "ArrowLeft", SelectLeft ) 112 | , ( "ArrowRight", SelectRight ) 113 | , ( "Tab", Deindent ) 114 | , ( "Home", SelectToLineStart ) 115 | , ( "End", SelectToLineEnd ) 116 | ] 117 | , option = 118 | Dict.fromList 119 | [ ( "ArrowUp", ScrollUp 20 ) 120 | , ( "ArrowDown", ScrollDown 20 ) 121 | , ( "ArrowLeft", CursorToLineStart ) 122 | , ( "ArrowRight", CursorToLineEnd ) 123 | ] 124 | , controlAndOption = 125 | Dict.fromList 126 | [ ( "ArrowUp", FirstLine ) 127 | , ( "ArrowDown", LastLine ) 128 | , ( "ArrowRight", CursorToGroupEnd ) 129 | , ( "ArrowLeft", CursorToGroupStart ) 130 | , ( "∑", ToggleWrapping ) 131 | , ( "ç", Clear ) 132 | ] 133 | , control = 134 | Dict.fromList 135 | [ ( "Backspace", RemoveGroupBefore ) 136 | , ( "Delete", RemoveGroupAfter ) 137 | , ( "d", SelectGroup ) 138 | , ( "c", Copy ) 139 | , ( "g", ToggleGoToLinePanel ) 140 | , ( ".", RollSearchSelectionForward ) 141 | , ( ",", RollSearchSelectionBackward ) 142 | , ( "h", ToggleHelp ) 143 | , ( "x", Cut ) 144 | , ( "s", ToggleSearchPanel ) 145 | , ( "r", ToggleReplacePanel ) 146 | , ( "v", Paste ) 147 | , ( "z", Undo ) 148 | , ( "w", WrapSelection ) 149 | , ( "y", Redo ) 150 | ] 151 | , controlAndShift = 152 | Dict.fromList 153 | [ ( "ArrowRight", SelectToGroupEnd ) 154 | , ( "ArrowLeft", SelectToGroupStart ) 155 | , ( "C", WriteToSystemClipBoard ) 156 | , ( "I", ToggleInfoPanel ) 157 | , ( "V", CopyPasteClipboard ) 158 | , ( "W", WrapAll ) 159 | , ( "S", SendLine ) 160 | , ( "A", SelectAll ) 161 | ] 162 | } 163 | 164 | 165 | keyToMsg : Keydown -> Decoder Msg 166 | keyToMsg event = 167 | let 168 | keyFrom keymap = 169 | Dict.get event.key keymap 170 | |> Maybe.map Decode.succeed 171 | |> Maybe.withDefault (Decode.fail "This key does nothing") 172 | 173 | keyOrCharFrom keymap = 174 | Decode.oneOf 175 | [ keyFrom keymap 176 | , event.char 177 | |> Maybe.map (Insert >> Decode.succeed) 178 | |> Maybe.withDefault 179 | (Decode.fail "This key does nothing") 180 | ] 181 | 182 | --_ = 183 | -- Debug.log "EV" event 184 | in 185 | case event.modifier of 186 | None -> 187 | keyOrCharFrom keymaps.noModifier 188 | 189 | Control -> 190 | keyFrom keymaps.control 191 | 192 | Shift -> 193 | keyOrCharFrom keymaps.shift 194 | 195 | ControlAndShift -> 196 | keyFrom keymaps.controlAndShift 197 | 198 | ControlAndOption -> 199 | keyFrom keymaps.controlAndOption 200 | 201 | Option -> 202 | keyFrom keymaps.option 203 | -------------------------------------------------------------------------------- /demo/src/AppText.elm: -------------------------------------------------------------------------------- 1 | module AppText exposing (gettysburgAddress, jabberwocky, long, longLines, tolstoy) 2 | 3 | 4 | gettysburgAddress = 5 | """ 6 | Below is Abraham Lincoln's Gettysburg Address. 7 | It was loaded as three long lines. This example 8 | illustrates the current state of the text-wrap 9 | functionality. It is based on Folkert de Vries' 10 | elm-paragraph library. 11 | 12 | Four score and seven years ago our fathers brought forth on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal. 13 | Now we are engaged in a great civil war, testing whether that nation, or any nation so conceived and so dedicated, can long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that nation might live. It is altogether fitting and proper that we should do this. 14 | 15 | But, in a larger sense, we can not dedicate—we can not consecrate—we can not hallow—this ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poor power to add or detract. The world will little note, nor long remember what we say here, but it can never forget what they did here. It is for us the living, rather, to be dedicated here to the unfinished work which they who fought here have thus far so nobly advanced. It is rather for us to be here dedicated to the great task remaining before us—that from these honored dead we take increased devotion to that cause for which they gave the last full measure of devotion—that we here highly resolve that these dead shall not have died in vain—that this nation, under God, shall have a new birth of freedom—and that government of the people, by the people, for the people, shall not perish from the earth.""" 16 | 17 | 18 | tolstoy = 19 | """“Well, Prince, so Genoa and Lucca are now just family 20 | estates of the Buonapartes. But I warn you, if you don’t 21 | tell me that this means war, if you still try to defend 22 | the infamies and horrors perpetrated by that Antichrist— 23 | I really believe he is Antichrist—I will have nothing 24 | more to do with you and you are no longer my friend, 25 | no longer my ‘faithful slave,’ as you call yourself! 26 | But how do you do? I see I have frightened you— 27 | sit down and tell me all the news. 28 | ”""" 29 | 30 | 31 | longLines = 32 | """ 33 | Bioluminescence might seem uncommon, even alien. But biologists think organisms evolved the ability to light up the dark as many as 50 different times, sending tendrils of self-powered luminosity coursing through the tree of life, from fireflies and vampire squids to lantern sharks and foxfire, a fungus found in rotting wood. 34 | 35 | Despite all this diversity, the general rules stay the same. Glowing in the dark or the deep takes two ingredients. You need some sort of luciferin, a molecule that can emit light. And you need an enzyme, luciferase, to trigger that reaction like the snapping of a glowstick. 36 | 37 | Some creatures delegate this chemistry to symbiotic bacteria. Others possess the genes to make their own versions of luciferin and luciferase. But then there’s the golden sweeper, a reef fish that evolved a trick that hasn’t been seen anywhere else, according to a study published Wednesday in Science Advances: It just gobbles up bioluminescent prey and borrows the entire kit. 38 | 39 | “If you can steal an already established, sophisticated system by eating somebody else, that’s way easier,” said Manabu Bessho-Uehara, a postdoctoral scholar at the Monterey Bay Aquarium Research Institute. 40 | 41 | ```elm 42 | update : Buffer -> Msg -> InternalState -> ( InternalState, Buffer, Cmd Msg ) 43 | update buffer msg state = 44 | case msg of 45 | NoOp -> 46 | ( state, buffer, Cmd.none ) 47 | 48 | FirstLine -> 49 | let 50 | cursor = 51 | { line = 0, column = 0 } 52 | 53 | window = 54 | Window.scrollToIncludeCursor cursor state.window 55 | in 56 | ( { state | cursor = cursor, window = window, selection = Nothing }, buffer, Cmd.none ) |> recordHistory state buffer 57 | 58 | AcceptLineNumber nString -> 59 | case String.toInt nString of 60 | Nothing -> 61 | ( state, buffer, Cmd.none ) 62 | 63 | Just n_ -> 64 | let 65 | n = 66 | clamp 0 (List.length (Buffer.lines buffer) - 1) (n_ - 1) 67 | 68 | cursor = 69 | { line = n, column = 0 } 70 | 71 | window = 72 | Window.scrollToIncludeCursor cursor state.window 73 | in 74 | ( { state | cursor = cursor, window = window, selection = Nothing }, buffer, Cmd.none ) |> recordHistory state buffer 75 | ``` 76 | 77 | 78 | The End 79 | """ 80 | 81 | 82 | long = 83 | List.repeat 30 jabberwocky |> String.join "\n----\n\n" 84 | 85 | 86 | jabberwocky = 87 | """Jabberwocky 88 | 89 | By Lewis Carroll 90 | 91 | ’Twas brillig, and the slithy toves 92 | Did gyre and gimble in the wabe: 93 | All mimsy were the borogoves, 94 | And the mome raths outgrabe. 95 | 96 | “Beware the Jabberwock, my son! 97 | The jaws that bite, the claws that catch! 98 | Beware the Jubjub bird, and shun 99 | The frumious Bandersnatch!” 100 | 101 | He took his vorpal sword in hand; 102 | Long time the manxome foe he sought— 103 | So rested he by the Tumtum tree 104 | And stood awhile in thought. 105 | 106 | And, as in uffish thought he stood, 107 | The Jabberwock, with eyes of flame, 108 | Came whiffling through the tulgey wood, 109 | And burbled as it came! 110 | 111 | One, two! One, two! And through and through 112 | The vorpal blade went snicker-snack! 113 | He left it dead, and with its head 114 | He went galumphing back. 115 | 116 | “And hast thou slain the Jabberwock? 117 | Come to my arms, my beamish boy! 118 | O frabjous day! Callooh! Callay!” 119 | He chortled in his joy. 120 | 121 | ’Twas brillig, and the slithy toves 122 | Did gyre and gimble in the wabe: 123 | All mimsy were the borogoves, 124 | And the mome raths outgrabe. 125 | 126 | PS. Here is the buried treasure. 127 | 128 | NOTES 129 | 130 | 1. The above text about "treasure" is **fake**. 131 | We were just testing to see if we could send 132 | the editor requests like "find the word 'treasure,' 133 | scroll down to it, and highlight it." 134 | 135 | 2. Now that this is working, we have a bit of 136 | code cleanup to do. And more work on some 137 | cursor and highight flakines, e.g., highlighting 138 | should be preserved when scrolling. 139 | 140 | 141 | """ 142 | -------------------------------------------------------------------------------- /src/Editor/Wrap.elm: -------------------------------------------------------------------------------- 1 | module Editor.Wrap exposing (paragraphs, runFSM) 2 | 3 | {-| Code for wrapping text. This needs more thought/work. 4 | -} 5 | 6 | import Dict exposing (Dict) 7 | import Editor.Config exposing (WrapParams) 8 | import Paragraph 9 | 10 | 11 | {-| Wrap text preserving paragraph structure and code blocks 12 | -} 13 | paragraphs : WrapParams -> String -> String 14 | paragraphs wrapParams str = 15 | str 16 | ++ "\n" 17 | |> runFSM 18 | |> List.filter (\( t, s ) -> s /= "") 19 | |> List.map (wrapParagraph wrapParams >> String.trim) 20 | |> String.join "\n\n" 21 | 22 | 23 | {-| Wrap text in paragraph if it is of ParagraphType, 24 | but not if it is code or block 25 | -} 26 | wrapParagraph : WrapParams -> ( ParagraphType, String ) -> String 27 | wrapParagraph wrapParams ( paragraphType, str ) = 28 | case paragraphType of 29 | TextParagraph -> 30 | Paragraph.lines wrapParams str |> String.join "\n" 31 | 32 | CodeParagraph -> 33 | str 34 | 35 | BlockParagraph -> 36 | str 37 | 38 | 39 | {-| Run a finite-state machine that gathers logical paragraphs 40 | into a list of tuples ( ParagraphType, String ) wheree the 41 | first component classifies the type of paragraph (as ParagraphType 42 | or CodeType 43 | -} 44 | runFSM : String -> List ( ParagraphType, String ) 45 | runFSM str = 46 | let 47 | lines = 48 | String.lines str 49 | 50 | initialData = 51 | ( Start, { currentParagraph = [], paragraphList = [], tick = 0 } ) 52 | in 53 | List.foldl nextState initialData lines 54 | |> Tuple.second 55 | |> .paragraphList 56 | |> List.reverse 57 | 58 | 59 | {-| Then next-state function for the finite-state machine. 60 | -} 61 | nextState : String -> ( State, Data ) -> ( State, Data ) 62 | nextState line ( state, data ) = 63 | let 64 | ( newState, action ) = 65 | nextStateAndAction line state 66 | in 67 | ( newState, action line data ) 68 | 69 | 70 | {-| A dictionary of functions which carry out the actions 71 | of the finite-state machine. 72 | -} 73 | opDict : Dict String (String -> Data -> Data) 74 | opDict = 75 | Dict.fromList 76 | [ ( "NoOp", \s d -> d ) 77 | 78 | -- 79 | , ( "StartParagraph", \s d -> { d | currentParagraph = [ s ], tick = d.tick + 1 } ) 80 | , ( "AddToParagraph", \s d -> { d | currentParagraph = s :: d.currentParagraph, tick = d.tick + 1 } ) 81 | , ( "EndParagraph", \s d -> { d | currentParagraph = [], paragraphList = ( TextParagraph, joinLines d.currentParagraph ) :: d.paragraphList } ) 82 | 83 | -- 84 | , ( "StartCodeFromBlank", \s d -> { d | currentParagraph = [ s ], paragraphList = ( TextParagraph, joinLines d.currentParagraph ) :: d.paragraphList, tick = d.tick + 1 } ) 85 | , ( "StartCodeFromParagraph", \s d -> { d | currentParagraph = [ s ], paragraphList = ( TextParagraph, joinLines d.currentParagraph ) :: d.paragraphList, tick = d.tick + 1 } ) 86 | , ( "StartCode", \s d -> { d | currentParagraph = [ s ], tick = d.tick + 1 } ) 87 | , ( "AddToCode", \s d -> { d | currentParagraph = s :: d.currentParagraph, tick = d.tick + 1 } ) 88 | , ( "EndCode", \s d -> { d | currentParagraph = [], paragraphList = ( CodeParagraph, joinLinesForCode <| s :: d.currentParagraph ) :: d.paragraphList } ) 89 | 90 | -- 91 | , ( "StartBlockFromBlank", \s d -> { d | currentParagraph = [ s ], paragraphList = ( TextParagraph, joinLines d.currentParagraph ) :: d.paragraphList, tick = d.tick + 1 } ) 92 | , ( "StartBlockFromParagraph", \s d -> { d | currentParagraph = [ s ], paragraphList = ( TextParagraph, joinLines d.currentParagraph ) :: d.paragraphList, tick = d.tick + 1 } ) 93 | , ( "StartBlock", \s d -> { d | currentParagraph = [ s ], tick = d.tick + 1 } ) 94 | , ( "AddToBlock", \s d -> { d | currentParagraph = s :: d.currentParagraph, tick = d.tick + 1 } ) 95 | , ( "EndBlock", \s d -> { d | currentParagraph = [], paragraphList = ( BlockParagraph, joinLinesForCode <| s :: d.currentParagraph ) :: d.paragraphList } ) 96 | ] 97 | 98 | 99 | {-| Look up the FSM function given its name. 100 | -} 101 | op : String -> (String -> Data -> Data) 102 | op opName = 103 | Dict.get opName opDict |> Maybe.withDefault (\_ d -> d) 104 | 105 | 106 | {-| Join the elements of a string lists with spaces. 107 | -} 108 | joinLines : List String -> String 109 | joinLines list = 110 | list 111 | |> List.reverse 112 | |> List.filter (\s -> s /= "") 113 | |> String.join " " 114 | 115 | 116 | {-| Join the elements of a string lists with newlines. 117 | -} 118 | joinLinesForCode : List String -> String 119 | joinLinesForCode list = 120 | list 121 | |> List.reverse 122 | |> String.join "\n" 123 | 124 | 125 | {-| Define the Finite State Machine 126 | -} 127 | nextStateAndAction : String -> State -> ( State, String -> Data -> Data ) 128 | nextStateAndAction line state = 129 | case ( state, classifyLine line ) of 130 | ( InParagraph, Text ) -> 131 | ( InParagraph, op "AddToParagraph" ) 132 | 133 | ( InParagraph, Blank ) -> 134 | ( InBlank, op "EndParagraph" ) 135 | 136 | ( InParagraph, CodeDelimiter ) -> 137 | ( InCode, op "StartCodeFromParagraph" ) 138 | 139 | ( InBlank, Blank ) -> 140 | ( InBlank, op "EndParagraph" ) 141 | 142 | ( InBlank, Text ) -> 143 | ( InParagraph, op "StartParagraph" ) 144 | 145 | ( InBlank, CodeDelimiter ) -> 146 | ( InCode, op "StartCodeFromBlank" ) 147 | 148 | ( Start, Text ) -> 149 | ( InParagraph, op "StartParagraph" ) 150 | 151 | ( Start, CodeDelimiter ) -> 152 | ( InCode, op "StartCode" ) 153 | 154 | ( Start, Blank ) -> 155 | ( Start, op "NoOp" ) 156 | 157 | ( InCode, CodeDelimiter ) -> 158 | ( Start, op "EndCode" ) 159 | 160 | ( InCode, Blank ) -> 161 | ( InCode, op "AddToCode" ) 162 | 163 | ( InCode, Text ) -> 164 | ( InCode, op "AddToCode" ) 165 | 166 | ( InBlank, BeginBlock ) -> 167 | ( InBlock, op "StartBlockFromBlank" ) 168 | 169 | ( InParagraph, BeginBlock ) -> 170 | ( InBlock, op "StartBlockFromParagraph" ) 171 | 172 | ( Start, BeginBlock ) -> 173 | ( InBlock, op "StartBlock" ) 174 | 175 | ( InBlock, EndBlock ) -> 176 | ( Start, op "EndBlock" ) 177 | 178 | ( InBlock, Blank ) -> 179 | ( InBlock, op "AddToBlock" ) 180 | 181 | ( InBlock, Text ) -> 182 | ( InBlock, op "AddToBlock" ) 183 | 184 | ( _, _ ) -> 185 | ( Start, op "NoOp" ) 186 | 187 | 188 | type State 189 | = Start 190 | | InParagraph 191 | | InBlank 192 | | InCode 193 | | InBlock 194 | 195 | 196 | type LineType 197 | = Blank 198 | | Text 199 | | CodeDelimiter 200 | | BeginBlock 201 | | EndBlock 202 | 203 | 204 | type ParagraphType 205 | = TextParagraph 206 | | CodeParagraph 207 | | BlockParagraph 208 | 209 | 210 | {-| Classify a line as Blank | CodeDelimiter or Text 211 | -} 212 | classifyLine : String -> LineType 213 | classifyLine str = 214 | let 215 | prefix = 216 | String.trimLeft str 217 | in 218 | if prefix == "" then 219 | Blank 220 | 221 | else if String.left 3 prefix == "```" then 222 | CodeDelimiter 223 | 224 | else if String.left 2 prefix == "$$" then 225 | CodeDelimiter 226 | -- haha 227 | 228 | else if String.left 6 prefix == "\\begin" then 229 | BeginBlock 230 | 231 | else if String.left 4 prefix == "\\end" then 232 | EndBlock 233 | 234 | else 235 | Text 236 | 237 | 238 | {-| The data structure on which the finite-state machine operates. 239 | -} 240 | type alias Data = 241 | { currentParagraph : List String 242 | , paragraphList : List ( ParagraphType, String ) 243 | , tick : Int 244 | } 245 | -------------------------------------------------------------------------------- /demo-simple/src/DemoSimple.elm: -------------------------------------------------------------------------------- 1 | module DemoSimple exposing (Msg(..), main) 2 | 3 | import AppText 4 | import Browser 5 | import Editor exposing (Editor, EditorConfig, EditorMsg) 6 | import Editor.Config exposing (WrapOption(..)) 7 | import Editor.Strings 8 | import Html exposing (Html, button, div, text) 9 | import Html.Attributes as HA exposing (style) 10 | import Html.Events exposing (onClick) 11 | 12 | 13 | main : Program () Model Msg 14 | main = 15 | Browser.element 16 | { init = init 17 | , view = view 18 | , update = update 19 | , subscriptions = subscriptions 20 | } 21 | 22 | 23 | 24 | -- INIT 25 | 26 | 27 | type Msg 28 | = EditorMsg EditorMsg 29 | | Test 30 | | FindTreasure 31 | | GetSpeech 32 | | Get500 33 | | Get1000 34 | | Get1500 35 | | Get3000 36 | | Jabberwocky 37 | | Code 38 | | About 39 | | LogErr String 40 | 41 | 42 | type alias Model = 43 | { editor : Editor 44 | , clipboard : String 45 | , document : Document 46 | } 47 | 48 | 49 | type Document 50 | = DocJabberWock 51 | | DocGettysburg 52 | | DocLongLines 53 | | Doc500 54 | | Doc1000 55 | | Doc1500 56 | | Doc3000 57 | | DocAbout 58 | | DocCode 59 | 60 | 61 | init : () -> ( Model, Cmd Msg ) 62 | init () = 63 | ( { editor = Editor.init config AppText.about 64 | , clipboard = "" 65 | , document = DocAbout 66 | } 67 | , Cmd.none 68 | ) 69 | 70 | 71 | config : EditorConfig Msg 72 | config = 73 | { editorMsg = EditorMsg 74 | , width = 500 75 | , height = 480 76 | , lineHeight = 16.0 77 | , showInfoPanel = True 78 | , wrapParams = { maximumWidth = 55, optimalWidth = 50, stringWidth = String.length } 79 | , wrapOption = DontWrap 80 | , fontProportion = 0.75 81 | , lineHeightFactor = 1.0 82 | } 83 | 84 | 85 | 86 | -- UPDATE 87 | 88 | 89 | update : Msg -> Model -> ( Model, Cmd Msg ) 90 | update msg model = 91 | case msg of 92 | EditorMsg editorMsg -> 93 | let 94 | ( editor, cmd ) = 95 | Editor.update editorMsg model.editor 96 | in 97 | ( { model | editor = editor }, Cmd.map EditorMsg cmd ) 98 | 99 | Test -> 100 | load DontWrap Editor.Strings.info model 101 | 102 | GetSpeech -> 103 | load DoWrap AppText.gettysburgAddress { model | document = DocGettysburg } 104 | 105 | Get500 -> 106 | load DontWrap AppText.words500 { model | document = Doc500 } 107 | 108 | Get1000 -> 109 | load DontWrap AppText.words1000 { model | document = Doc1000 } 110 | 111 | Get1500 -> 112 | load DontWrap AppText.words1500 { model | document = Doc1500 } 113 | 114 | Get3000 -> 115 | load DontWrap AppText.words3000 { model | document = Doc3000 } 116 | 117 | Jabberwocky -> 118 | load DontWrap AppText.jabberwocky { model | document = DocJabberWock } 119 | 120 | About -> 121 | load DontWrap AppText.about { model | document = DocAbout } 122 | 123 | Code -> 124 | load DontWrap AppText.code { model | document = DocCode } 125 | 126 | FindTreasure -> 127 | highlightText "treasure" model 128 | 129 | LogErr _ -> 130 | ( model, Cmd.none ) 131 | 132 | 133 | 134 | -- HELPER FUNCTIONS FOR UPDATE 135 | 136 | 137 | {-| Paste contents of clipboard into Editor 138 | -} 139 | pasteToClipboard : Model -> String -> ( Model, Cmd msg ) 140 | pasteToClipboard model editor = 141 | ( { model 142 | | editor = 143 | Editor.insert 144 | (Editor.getWrapOption model.editor) 145 | (Editor.getCursor model.editor) 146 | editor 147 | model.editor 148 | } 149 | , Cmd.none 150 | ) 151 | 152 | 153 | pasteToEditorClipboard : Model -> String -> ( Model, Cmd msg ) 154 | pasteToEditorClipboard model str = 155 | let 156 | cursor = 157 | Editor.getCursor model.editor 158 | 159 | wrapOption = 160 | Editor.getWrapOption model.editor 161 | 162 | editor2 = 163 | Editor.placeInClipboard str model.editor 164 | in 165 | ( { model | editor = Editor.insert wrapOption cursor str editor2 }, Cmd.none ) 166 | 167 | 168 | {-| Load text into Editor 169 | -} 170 | load : WrapOption -> String -> Model -> ( Model, Cmd Msg ) 171 | load wrapOption text model = 172 | let 173 | newEditor = 174 | Editor.load wrapOption text model.editor 175 | in 176 | ( { model | editor = newEditor }, Cmd.none ) 177 | 178 | 179 | {-| Find str and highlight it 180 | -} 181 | highlightText : String -> Model -> ( Model, Cmd Msg ) 182 | highlightText str model = 183 | let 184 | newEditor = 185 | Editor.scrollToString str model.editor 186 | in 187 | ( { model | editor = newEditor }, Cmd.none ) 188 | 189 | 190 | 191 | -- SUBSCRIPTIONS 192 | 193 | 194 | subscriptions : Model -> Sub Msg 195 | subscriptions model = 196 | Sub.none 197 | 198 | 199 | 200 | -- VIEW 201 | 202 | 203 | view : Model -> Html Msg 204 | view model = 205 | div 206 | [ HA.style "margin" "60px" 207 | ] 208 | [ title 209 | , div [ HA.style "width" (px <| Editor.getWidth model.editor) ] [ Editor.embedded config model.editor ] 210 | , footer model 211 | ] 212 | 213 | 214 | px : Float -> String 215 | px p = 216 | String.fromFloat p ++ "px" 217 | 218 | 219 | title : Html Msg 220 | title = 221 | div 222 | [ HA.style "font-size" "16px" 223 | , HA.style "font-style" "bold" 224 | , HA.style "margin-bottom" "10px" 225 | ] 226 | [ text "A Pure Elm Text Editor (Simple)" ] 227 | 228 | 229 | footer : Model -> Html Msg 230 | footer model = 231 | div 232 | [ HA.style "font-size" "14px", HA.style "position" "absolute", HA.style "top" "590px", HA.style "left" "80px" ] 233 | [ div [] 234 | [ Html.a [ HA.href "https://github.com/jxxcarlson/elm-text-editor" ] [ text "Source code (Work in Progress)" ] 235 | ] 236 | , div [ HA.style "margin-top" "10px" ] 237 | [ text "This editor is based on " 238 | , Html.a [ HA.href "https://sidneynemzer.github.io/elm-text-editor/" ] 239 | [ text "work of Sydney Nemzer" ] 240 | , Html.span [] [ text " and is inspired by previous work of " ] 241 | , Html.a [ HA.href "https://discourse.elm-lang.org/t/text-editor-done-in-pure-elm/1365" ] [ text "Martin Janiczek" ] 242 | ] 243 | , div [ HA.style "margin-top" "10px" ] [ text "ctrl-h to toggle help, ctrl-shift-w to wrap all text" ] 244 | , div [ HA.style "margin-top" "10px" ] 245 | [ aboutButton model 246 | , codeButton model 247 | , jabberWockyButton model 248 | , speechTextButton model 249 | , textButton500 model 250 | , textButton1000 model 251 | , textButton1500 model 252 | , textButton3000 model 253 | ] 254 | ] 255 | 256 | 257 | 258 | -- BUTTONS 259 | 260 | 261 | speechTextButton model = 262 | rowButton model 160 GetSpeech DocGettysburg "Gettysburg Address" [] 263 | 264 | 265 | textButton500 model = 266 | rowButton model 100 Get500 Doc500 "500 words" [] 267 | 268 | 269 | textButton1000 model = 270 | rowButton model 100 Get1000 Doc1000 "1000 words" [] 271 | 272 | 273 | textButton1500 model = 274 | rowButton model 100 Get1500 Doc1500 "1500 words" [] 275 | 276 | 277 | textButton3000 model = 278 | rowButton model 100 Get3000 Doc3000 "3000 words" [] 279 | 280 | 281 | jabberWockyButton model = 282 | rowButton model 100 Jabberwocky DocJabberWock "Jabberwocky" [] 283 | 284 | 285 | aboutButton model = 286 | rowButton model 80 About DocAbout "About" [] 287 | 288 | 289 | codeButton model = 290 | rowButton model 80 Code DocCode "Code" [] 291 | 292 | 293 | highlight : b -> b -> List (Html.Attribute msg) 294 | highlight source target = 295 | case source == target of 296 | True -> 297 | [ style "background-color" "#900" ] 298 | 299 | False -> 300 | [ style "background-color" "#666" ] 301 | 302 | 303 | 304 | -- STYLE -- 305 | 306 | 307 | rowButtonStyle = 308 | [ style "font-size" "12px" 309 | , style "border" "none" 310 | , style "margin-right" "8px" 311 | , style "float" "left" 312 | ] 313 | 314 | 315 | rowButtonLabelStyle width = 316 | [ style "font-size" "12px" 317 | , style "color" "#eee" 318 | , style "width" (String.fromInt width ++ "px") 319 | , style "height" "24px" 320 | , style "border" "none" 321 | ] 322 | 323 | 324 | 325 | -- rowButton : Model -> Int -> b -> Document -> String -> List (Html.Attribute msg) -> Html Msg 326 | 327 | 328 | rowButton model width msg doc str attr = 329 | div (rowButtonStyle ++ attr) 330 | [ button ([ onClick msg ] ++ rowButtonLabelStyle width ++ highlight doc model.document) [ text str ] ] 331 | -------------------------------------------------------------------------------- /demo-simple/src/AppText.elm: -------------------------------------------------------------------------------- 1 | module AppText exposing (about, code, gettysburgAddress, jabberwocky, long, longLines, tolstoy, words1000, words1500, words3000, words500) 2 | 3 | 4 | about = 5 | """ 6 | A bunch of test files here. 7 | 8 | **Main Problems:** 9 | 10 | - Performance: laggy over 1500 words 11 | 12 | """ 13 | 14 | 15 | code = 16 | """ 17 | {-| 18 | 19 | indexedFilterMap (\\s -> s == "") 20 | ["red" ,"green", "", "", "blue", "", "purple"] 21 | --> [2,3,5] : List Int 22 | 23 | -} 24 | indexedFilterMap : (a -> Bool) -> List a -> List Int 25 | indexedFilterMap filter list = 26 | list 27 | |> List.indexedMap (\\k item -> ( k, item )) 28 | |> List.filter (\\( k, item ) -> filter item) 29 | |> List.map Tuple.first 30 | 31 | {-| Function to find the index of the first 32 | blank line before the index of a given line 33 | -} 34 | selectIndexOfPrecedingParagraph : String -> Int -> Maybe Int 35 | selectIndexOfPrecedingParagraph str end = 36 | let 37 | blankLines_ = 38 | indexedFilterMap 39 | (\\str_ -> str_ == "") 40 | (String.lines str) 41 | 42 | indexOfStart = 43 | List.filter 44 | (\\i -> i < end) 45 | blankLines_ |> List.Extra.last 46 | in 47 | case indexOfStart of 48 | Nothing -> 49 | Nothing 50 | 51 | Just i -> 52 | Just (i + 1) 53 | 54 | 55 | {-| Function to select the paragraph 56 | before the given position 57 | -} 58 | selectPreviousParagraph : Buffer -> Position -> Maybe Position 59 | selectPreviousParagraph (Buffer str) end = 60 | selectIndexOfPrecedingParagraph str end.line 61 | |> Maybe.map (\\line_ -> Position line_ 0) 62 | 63 | """ 64 | 65 | 66 | indexedFilterMap : (a -> Bool) -> List a -> List Int 67 | indexedFilterMap filter list = 68 | list 69 | |> List.indexedMap (\k item -> ( k, item )) 70 | |> List.filter (\( k, item ) -> filter item) 71 | |> List.map Tuple.first 72 | 73 | 74 | words500 = 75 | """ 76 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut sed turpis est. 77 | Proin vestibulum, nunc ornare auctor vulputate, quam metus porta dolor, a 78 | hendrerit ante ipsum sed ligula. Morbi metus mauris, fermentum dictum blandit 79 | et, malesuada vitae sem. In ante erat, pulvinar nec volutpat ac, volutpat non 80 | risus. Cras elit ligula, volutpat a semper sit amet, ullamcorper fringilla 81 | velit. Vestibulum at nisl vehicula risus egestas faucibus non id elit. Nulla 82 | pharetra vestibulum placerat. Cras ante odio, ullamcorper cursus ante vel, 83 | bibendum suscipit risus. Mauris commodo libero vitae leo cursus, quis hendrerit 84 | orci lobortis. 85 | 86 | In mattis pretium dapibus. Vestibulum ante ipsum primis in faucibus orci luctus 87 | et ultrices posuere cubilia Curae; Suspendisse porta justo a magna dictum 88 | ullamcorper. Donec congue ornare risus sit amet dictum. Phasellus pulvinar vitae 89 | erat et elementum. Pellentesque eget accumsan dui, sed porta diam. Suspendisse 90 | molestie est quis ante vestibulum, et imperdiet odio laoreet. Cras vehicula 91 | risus vel rhoncus laoreet. Nullam laoreet cursus consectetur. 92 | 93 | Proin non mauris in nisi tempor euismod in in mauris. Morbi eu accumsan lacus. 94 | Morbi tincidunt ipsum sit amet lacus vestibulum, non sollicitudin erat 95 | fringilla. Mauris mauris leo, efficitur eu nunc sed, eleifend suscipit nisi. 96 | Cras at augue quis eros mollis fermentum vitae accumsan elit. Aenean interdum 97 | varius elit, scelerisque varius magna consectetur in. In non ex pretium, lacinia 98 | ipsum vel, ullamcorper dolor. Nulla fringilla sagittis venenatis. Suspendisse 99 | aliquam lectus nec leo rutrum laoreet. 100 | 101 | Curabitur ut efficitur erat. Duis ac leo vel massa porttitor sodales. Fusce 102 | interdum leo quis tempus faucibus. Aliquam erat volutpat. Maecenas iaculis 103 | libero lorem. Praesent neque odio, blandit et lobortis eu, ullamcorper a risus. 104 | Fusce pellentesque ligula eget risus maximus laoreet. 105 | 106 | Curabitur tellus neque, malesuada vitae nisi et, volutpat tincidunt mi. Duis 107 | posuere, lacus nec dapibus laoreet, urna odio volutpat enim, et facilisis sem 108 | elit quis diam. Nam orci eros, laoreet eu sagittis et, malesuada et diam. 109 | Suspendisse malesuada nulla in finibus porttitor. Donec consequat felis eu leo 110 | gravida, quis cursus leo auctor. Maecenas libero neque, aliquam ac consequat ut, 111 | ullamcorper id nibh. Fusce semper lobortis tortor, a ornare sapien bibendum vel. 112 | Quisque quis rhoncus justo. Nam vitae feugiat est, eu vehicula turpis. Quisque 113 | bibendum ante quis diam semper, eget tincidunt lacus sagittis. In et justo 114 | lorem. Cras ultrices nisl porttitor placerat efficitur. Suspendisse potenti. 115 | 116 | Aliquam mauris leo, egestas sit amet condimentum ac, imperdiet eu justo. Nulla 117 | quis semper elit. Aenean gravida elementum lectus eget pellentesque. Mauris at 118 | tortor eu nisi porttitor feugiat. Pellentesque vulputate lorem sit amet mattis 119 | iaculis. Curabitur viverra imperdiet odio. Cras ut turpis ut velit hendrerit 120 | congue. Curabitur fringilla, dui et auctor condimentum, tellus nulla varius est, 121 | at finibus tellus magna et dolor. Sed porta suscipit ornare. Praesent 122 | pellentesque ex a porta aliquam. Nunc auctor ullamcorper urna. 123 | 124 | Aliquam erat volutpat. Curabitur auctor sed sem non venenatis. Fusce iaculis 125 | ante a nisi vehicula mattis. Proin facilisis quis sapien eget sodales. Maecenas 126 | pulvinar mauris ut massa pulvinar venenatis. In dignissim lorem ut viverra 127 | scelerisque. Morbi turpis enim, efficitur non pharetra consequat, gravida vitae 128 | neque. Proin lacus augue. 129 | 130 | """ 131 | 132 | 133 | words1000 = 134 | words500 ++ words500 135 | 136 | 137 | words1500 = 138 | words1000 ++ words500 139 | 140 | 141 | words3000 = 142 | words1500 ++ words1500 143 | 144 | 145 | gettysburgAddress = 146 | """ 147 | Below is Abraham Lincoln's Gettysburg Address. 148 | It was loaded as three long lines. This example 149 | illustrates the current state of the text-wrap 150 | functionality. It is based on Folkert de Vries' 151 | elm-paragraph library. 152 | 153 | Four score and seven years ago our fathers brought forth on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal. 154 | Now we are engaged in a great civil war, testing whether that nation, or any nation so conceived and so dedicated, can long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that nation might live. It is altogether fitting and proper that we should do this. 155 | 156 | But, in a larger sense, we can not dedicate—we can not consecrate—we can not hallow—this ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poor power to add or detract. The world will little note, nor long remember what we say here, but it can never forget what they did here. It is for us the living, rather, to be dedicated here to the unfinished work which they who fought here have thus far so nobly advanced. It is rather for us to be here dedicated to the great task remaining before us—that from these honored dead we take increased devotion to that cause for which they gave the last full measure of devotion—that we here highly resolve that these dead shall not have died in vain—that this nation, under God, shall have a new birth of freedom—and that government of the people, by the people, for the people, shall not perish from the earth.""" 157 | 158 | 159 | tolstoy = 160 | """“Well, Prince, so Genoa and Lucca are now just family 161 | estates of the Buonapartes. But I warn you, if you don’t 162 | tell me that this means war, if you still try to defend 163 | the infamies and horrors perpetrated by that Antichrist— 164 | I really believe he is Antichrist—I will have nothing 165 | more to do with you and you are no longer my friend, 166 | no longer my ‘faithful slave,’ as you call yourself! 167 | But how do you do? I see I have frightened you— 168 | sit down and tell me all the news. 169 | ”""" 170 | 171 | 172 | longLines = 173 | """ 174 | Bioluminescence might seem uncommon, even alien. But biologists think organisms evolved the ability to light up the dark as many as 50 different times, sending tendrils of self-powered luminosity coursing through the tree of life, from fireflies and vampire squids to lantern sharks and foxfire, a fungus found in rotting wood. 175 | 176 | Despite all this diversity, the general rules stay the same. Glowing in the dark or the deep takes two ingredients. You need some sort of luciferin, a molecule that can emit light. And you need an enzyme, luciferase, to trigger that reaction like the snapping of a glowstick. 177 | 178 | Some creatures delegate this chemistry to symbiotic bacteria. Others possess the genes to make their own versions of luciferin and luciferase. But then there’s the golden sweeper, a reef fish that evolved a trick that hasn’t been seen anywhere else, according to a study published Wednesday in Science Advances: It just gobbles up bioluminescent prey and borrows the entire kit. 179 | 180 | “If you can steal an already established, sophisticated system by eating somebody else, that’s way easier,” said Manabu Bessho-Uehara, a postdoctoral scholar at the Monterey Bay Aquarium Research Institute. 181 | 182 | ```elm 183 | update : Buffer -> Msg -> InternalState -> ( InternalState, Buffer, Cmd Msg ) 184 | update buffer msg state = 185 | case msg of 186 | NoOp -> 187 | ( state, buffer, Cmd.none ) 188 | 189 | FirstLine -> 190 | let 191 | cursor = 192 | { line = 0, column = 0 } 193 | 194 | window = 195 | Window.scrollToIncludeCursor cursor state.window 196 | in 197 | ( { state | cursor = cursor, window = window, selection = Nothing }, buffer, Cmd.none ) |> recordHistory state buffer 198 | 199 | AcceptLineNumber nString -> 200 | case String.toInt nString of 201 | Nothing -> 202 | ( state, buffer, Cmd.none ) 203 | 204 | Just n_ -> 205 | let 206 | n = 207 | clamp 0 (List.length (Buffer.lines buffer) - 1) (n_ - 1) 208 | 209 | cursor = 210 | { line = n, column = 0 } 211 | 212 | window = 213 | Window.scrollToIncludeCursor cursor state.window 214 | in 215 | ( { state | cursor = cursor, window = window, selection = Nothing }, buffer, Cmd.none ) |> recordHistory state buffer 216 | ``` 217 | 218 | 219 | The End 220 | """ 221 | 222 | 223 | long = 224 | List.repeat 30 jabberwocky |> String.join "\n----\n\n" 225 | 226 | 227 | jabberwocky = 228 | """Jabberwocky 229 | 230 | By Lewis Carroll 231 | 232 | ’Twas brillig, and the slithy toves 233 | Did gyre and gimble in the wabe: 234 | All mimsy were the borogoves, 235 | And the mome raths outgrabe. 236 | 237 | “Beware the Jabberwock, my son! 238 | The jaws that bite, the claws that catch! 239 | Beware the Jubjub bird, and shun 240 | The frumious Bandersnatch!” 241 | 242 | He took his vorpal sword in hand; 243 | Long time the manxome foe he sought— 244 | So rested he by the Tumtum tree 245 | And stood awhile in thought. 246 | 247 | And, as in uffish thought he stood, 248 | The Jabberwock, with eyes of flame, 249 | Came whiffling through the tulgey wood, 250 | And burbled as it came! 251 | 252 | One, two! One, two! And through and through 253 | The vorpal blade went snicker-snack! 254 | He left it dead, and with its head 255 | He went galumphing back. 256 | 257 | “And hast thou slain the Jabberwock? 258 | Come to my arms, my beamish boy! 259 | O frabjous day! Callooh! Callay!” 260 | He chortled in his joy. 261 | 262 | ’Twas brillig, and the slithy toves 263 | Did gyre and gimble in the wabe: 264 | All mimsy were the borogoves, 265 | And the mome raths outgrabe. 266 | 267 | PS. Here is the buried treasure. 268 | 269 | NOTES 270 | 271 | 1. The above text about "treasure" is **fake**. 272 | We were just testing to see if we could send 273 | the editor requests like "find the word 'treasure,' 274 | scroll down to it, and highlight it." 275 | 276 | 2. Now that this is working, we have a bit of 277 | code cleanup to do. And more work on some 278 | cursor and highight flakines, e.g., highlighting 279 | should be preserved when scrolling. 280 | 281 | 282 | """ 283 | -------------------------------------------------------------------------------- /src/Editor/View.elm: -------------------------------------------------------------------------------- 1 | module Editor.View exposing (view) 2 | 3 | import Char 4 | import Editor.Config as Config exposing (WrapOption(..)) 5 | import Editor.Keymap 6 | import Editor.Model exposing (InternalState) 7 | import Editor.Style as Style 8 | import Editor.Update exposing (Msg(..)) 9 | import Editor.Widget as Widget 10 | import Html exposing (Attribute, Html, div, span, text) 11 | import Html.Attributes as Attribute exposing (class, classList, style) 12 | import Html.Events as Event 13 | import Html.Lazy 14 | import Json.Decode as Decode 15 | import List.Extra 16 | import Position exposing (Position) 17 | import RollingList 18 | 19 | 20 | name : String 21 | name = 22 | "elm-editor" 23 | 24 | 25 | selected : Position -> Maybe Position -> Position -> Bool 26 | selected cursor maybeSelection char = 27 | maybeSelection 28 | |> Maybe.map (\selection -> Position.between cursor selection char) 29 | |> Maybe.withDefault False 30 | 31 | 32 | {-| The non-breaking space character will not get whitespace-collapsed like a 33 | regular space. 34 | -} 35 | nonBreakingSpace : Char 36 | nonBreakingSpace = 37 | Char.fromCode 160 38 | 39 | 40 | 41 | {- 42 | 43 | The original code. It incurs a HUGE 44 | performance penalty, resulting in laggy 45 | performance by the editor. 46 | 47 | ensureNonBreakingSpace : Char -> Char 48 | ensureNonBreakingSpace char = 49 | if char == ' ' then 50 | nonBreakingSpace 51 | 52 | else 53 | char 54 | 55 | -} 56 | 57 | 58 | {-| This is Folkert's much better version. 59 | -} 60 | ensureNonBreakingSpace : Char -> Char 61 | ensureNonBreakingSpace char = 62 | case char of 63 | ' ' -> 64 | nonBreakingSpace 65 | 66 | _ -> 67 | char 68 | 69 | 70 | withTrue : a -> ( a, Bool ) 71 | withTrue a = 72 | ( a, True ) 73 | 74 | 75 | captureOnMouseDown : Msg -> Attribute Msg 76 | captureOnMouseDown msg = 77 | Event.stopPropagationOn 78 | "mousedown" 79 | (Decode.map withTrue (Decode.succeed msg)) 80 | 81 | 82 | captureOnMouseOver : Msg -> Attribute Msg 83 | captureOnMouseOver msg = 84 | Event.stopPropagationOn 85 | "mouseover" 86 | (Decode.map withTrue (Decode.succeed msg)) 87 | 88 | 89 | character : Position -> Maybe Position -> Position -> Char -> Html Msg 90 | character cursor selection position char = 91 | span 92 | [ classList 93 | [ ( name ++ "-line__character", True ) 94 | , ( name ++ "-line__character--has-cursor", cursor == position ) 95 | , ( name ++ "-line__character--selected" 96 | , selected cursor selection position 97 | ) 98 | ] 99 | , captureOnMouseDown (MouseDown position) 100 | , captureOnMouseOver (MouseOver position) 101 | ] 102 | [ text <| String.fromChar <| ensureNonBreakingSpace char 103 | , if cursor == position then 104 | span [ class <| name ++ "-cursor" ] [ text " " ] 105 | 106 | else 107 | text "" 108 | ] 109 | 110 | 111 | line : Position -> Maybe Position -> Int -> String -> Html Msg 112 | line cursor selection index content = 113 | let 114 | length = 115 | String.length content 116 | 117 | {- Add offset to index to compensate for scrolling -} 118 | endPosition = 119 | { line = index, column = length } 120 | in 121 | div 122 | [ class <| name ++ "-line" 123 | , style "white-space" "pre-wrap" 124 | , captureOnMouseDown (MouseDown endPosition) 125 | , captureOnMouseOver (MouseOver endPosition) 126 | ] 127 | <| 128 | List.concat 129 | [ [ span 130 | [ class <| name ++ "-line__gutter-padding" 131 | , captureOnMouseDown (MouseDown { line = index + 0, column = 0 }) 132 | , captureOnMouseOver (MouseOver { line = index + 0, column = 0 }) 133 | ] 134 | [ text <| String.fromChar nonBreakingSpace ] 135 | ] 136 | , List.indexedMap 137 | (Position index >> character cursor selection) 138 | (String.toList content) 139 | , if index == cursor.line && cursor.column >= length then 140 | [ span 141 | [ class <| name ++ "-line__character" 142 | , class <| name ++ "-line__character--has-cursor" 143 | ] 144 | [ text " " 145 | , span [ class <| name ++ "-cursor" ] [ text " " ] 146 | ] 147 | ] 148 | 149 | else 150 | [] 151 | ] 152 | 153 | 154 | onTripleClick : msg -> Attribute msg 155 | onTripleClick msg = 156 | Event.on 157 | "click" 158 | (Decode.field "detail" Decode.int 159 | |> Decode.andThen 160 | (\detail -> 161 | if detail >= 3 then 162 | Decode.succeed msg 163 | 164 | else 165 | Decode.fail "" 166 | ) 167 | ) 168 | 169 | 170 | lineNumber : Int -> Html Msg 171 | lineNumber number = 172 | span 173 | [ class <| name ++ "-line-number" 174 | , captureOnMouseDown (MouseDown { line = number, column = 0 }) 175 | , captureOnMouseDown SelectLine 176 | , captureOnMouseOver (MouseOver { line = number, column = 0 }) 177 | ] 178 | [ text <| String.fromInt (number + 0) ] 179 | 180 | 181 | gutter : Int -> Html Msg 182 | gutter maxLines_ = 183 | -- XXX: Todo: rationalize maxlines 184 | div [ class <| name ++ "-gutter" ] <| 185 | List.map lineNumber (List.range 1 maxLines_) 186 | 187 | 188 | linesContainer : List (Html Msg) -> Html Msg 189 | linesContainer = 190 | div [ class <| name ++ "-lines" ] 191 | 192 | 193 | view : List (Attribute Msg) -> List String -> InternalState -> Html Msg 194 | view attr lines state = 195 | div [] 196 | [ div [] 197 | [ showIf state.showGoToLinePanel (goToLinePanel state) 198 | , showIf state.showSearchPanel (searchPanel state) 199 | , showIf (not (state.showSearchPanel || state.showGoToLinePanel)) (headerPanel state lines) 200 | ] 201 | , Html.Lazy.lazy3 innerView attr lines state 202 | ] 203 | 204 | 205 | px : Float -> String 206 | px p = 207 | String.fromFloat p ++ "px" 208 | 209 | 210 | innerView : List (Attribute Msg) -> List String -> InternalState -> Html Msg 211 | innerView attr lines state = 212 | div (attr ++ [ Attribute.class "flex-column" ]) 213 | [ div 214 | [ class <| name ++ "-container" 215 | , Event.preventDefaultOn 216 | "keydown" 217 | (Decode.map withTrue Editor.Keymap.decoder) 218 | , Event.onMouseUp MouseUp 219 | , Event.onDoubleClick SelectGroup 220 | , onTripleClick SelectLine 221 | , Attribute.tabindex 0 222 | ] 223 | [ gutter (max 100 (List.length lines + 20)) 224 | , linesContainer <| 225 | List.indexedMap (line state.cursor state.selection) lines 226 | ] 227 | ] 228 | 229 | 230 | roundTo : Int -> Float -> Float 231 | roundTo places x = 232 | let 233 | pp = 234 | places |> toFloat 235 | 236 | factor = 237 | 10 ^ pp 238 | in 239 | x * factor |> round |> (\u -> toFloat u / factor) 240 | 241 | 242 | currentLineLength : InternalState -> List String -> Html Msg 243 | currentLineLength state lines = 244 | let 245 | lineLength = 246 | List.Extra.getAt state.cursor.line lines 247 | |> Maybe.map (String.length >> String.fromInt) 248 | |> Maybe.withDefault "-1" 249 | in 250 | div [ style "margin-top" "10px" ] [ text <| "Length: " ++ lineLength ] 251 | 252 | 253 | wrappingOption state = 254 | let 255 | message = 256 | if state.config.wrapOption == DoWrap then 257 | "Wrap: ON" 258 | 259 | else 260 | "Wrap: OFF" 261 | in 262 | div [ style "margin-top" "10px" ] [ text message ] 263 | 264 | 265 | infoPanelStyle = 266 | [ style "width" "100px" 267 | , style "position" "fixed" 268 | , style "right" "8px" 269 | , style "top" "8px" 270 | , style "opacity" "1.0" 271 | , style "border" "solid 0.5px #444" 272 | , style "background-color" Style.lightGray 273 | , style "padding" "8px" 274 | , style "z-index" "100" 275 | ] 276 | 277 | 278 | searchPanel state = 279 | if state.showSearchPanel == True then 280 | searchPanel_ state 281 | 282 | else 283 | div [] [] 284 | 285 | 286 | headerPanel state lines = 287 | div (headerPanelStyle state.config.width) 288 | [ toggleHelpButtonHeader state, wordCount lines, lineCount lines, wrappingOptionDisplay state, cursorPosition state lines ] 289 | 290 | 291 | toggleHelpButtonHeader state = 292 | let 293 | label = 294 | if state.showHelp == True then 295 | "Help" 296 | 297 | else 298 | "Back" 299 | in 300 | Widget.rowButton 60 ToggleHelp label [ style "height" "25px", style "margin-top" "-3px", style "margin-left" "-30px" ] 301 | 302 | 303 | wrappingOptionDisplay : InternalState -> Html Msg 304 | wrappingOptionDisplay state = 305 | let 306 | message = 307 | if state.config.wrapOption == DoWrap then 308 | "Wrap: ON" 309 | 310 | else 311 | "Wrap: OFF" 312 | in 313 | div (Widget.headingStyle ++ [ style "margin-top" "2px" ]) [ text message ] 314 | 315 | 316 | headerPanelStyle width = 317 | [ style "width" (px (width - 40)) 318 | , style "padding-top" "10px" 319 | , style "height" "27px" 320 | , style "background-color" Style.lightGray 321 | , style "opacity" "0.8" 322 | , style "font-size" "14px" 323 | , style "padding-left" "40px" 324 | , Attribute.class "flex-row" 325 | ] 326 | 327 | 328 | searchPanel_ state = 329 | div 330 | [ style "width" "595px" 331 | , style "padding-top" "5px" 332 | , style "height" "30px" 333 | , style "padding-left" "8px" 334 | , style "background-color" Style.lightGray 335 | , style "opacity" "0.9" 336 | , style "font-size" "14px" 337 | , style "float" "left" 338 | ] 339 | [ searchTextButton 340 | , acceptSearchText 341 | , numberOfHitsDisplay state 342 | 343 | -- , syncButton 344 | , showIf (not state.canReplace) openReplaceField 345 | , showIf state.canReplace replaceTextButton 346 | , showIf state.canReplace acceptReplaceText 347 | , searchForwardButton 348 | , searchBackwardButton 349 | , dismissSearchPanel 350 | ] 351 | 352 | 353 | goToLinePanel state = 354 | if state.showGoToLinePanel == True then 355 | goToLinePanel_ state.config.width 356 | 357 | else 358 | div [] [] 359 | 360 | 361 | goToLinePanel_ width = 362 | div 363 | [ style "width" (px width) 364 | , style "height" "34px" 365 | , style "padding" "1px" 366 | , style "opacity" "0.9" 367 | , style "background-color" Style.lightGray 368 | , style "float" "left" 369 | ] 370 | [ goToLineButton 371 | , acceptLineNumber 372 | , dismissGoToLineButton 373 | ] 374 | 375 | 376 | dismissGoToLineButton = 377 | Widget.lightRowButton 25 378 | ToggleGoToLinePanel 379 | "X" 380 | [ style "margin-top" "5px" 381 | , style "float" "left" 382 | ] 383 | 384 | 385 | numberOfHitsDisplay : InternalState -> Html Msg 386 | numberOfHitsDisplay state = 387 | let 388 | n = 389 | state.searchResults 390 | |> RollingList.toList 391 | |> List.length 392 | 393 | txt = 394 | String.fromInt (state.searchHitIndex + 1) ++ "/" ++ String.fromInt n 395 | in 396 | Widget.rowButton 40 NoOp txt [ style "float" "left" ] 397 | 398 | 399 | lineCount : List String -> Html Msg 400 | lineCount lines = 401 | div (Widget.headingStyle ++ [ style "margin-top" "2px" ]) [ text ("Lines: " ++ String.fromInt (List.length lines)) ] 402 | 403 | 404 | wordCount : List String -> Html Msg 405 | wordCount lines = 406 | let 407 | words = 408 | List.map String.words lines |> List.concat 409 | in 410 | div (Widget.headingStyle ++ [ style "margin-top" "2px" ]) [ text ("Words: " ++ String.fromInt (List.length words)) ] 411 | 412 | 413 | cursorPosition : InternalState -> List String -> Html Msg 414 | cursorPosition state lines = 415 | let 416 | ll = 417 | List.Extra.getAt state.cursor.line lines 418 | |> Maybe.map (String.length >> String.fromInt) 419 | |> Maybe.withDefault "-1" 420 | 421 | r = 422 | String.fromInt (state.cursor.line + 1) 423 | 424 | c = 425 | String.fromInt state.cursor.column 426 | in 427 | div (Widget.headingStyle ++ [ style "margin-top" "2px" ]) [ text ("Cursor = (" ++ r ++ ", " ++ c ++ ", " ++ ll ++ ")") ] 428 | 429 | 430 | 431 | -- BUTTONS -- 432 | 433 | 434 | toggleHelpButton state = 435 | let 436 | label = 437 | if state.showHelp == True then 438 | "Help" 439 | 440 | else 441 | "Back" 442 | in 443 | Widget.columnButton 80 ToggleHelp label [] 444 | 445 | 446 | goToLineButton = 447 | Widget.rowButton 80 448 | NoOp 449 | "Go to line" 450 | [ style "margin-top" "5px", style "margin-left" "5px", style "float" "left" ] 451 | 452 | 453 | dismissInfoPanel = 454 | Widget.lightColumnButton 20 455 | ToggleInfoPanel 456 | "X" 457 | [] 458 | 459 | 460 | dismissSearchPanel = 461 | Widget.lightRowButton 25 462 | ToggleSearchPanel 463 | "X" 464 | [ style "float" "left", style "float" "left" ] 465 | 466 | 467 | openReplaceField = 468 | Widget.rowButton 25 469 | OpenReplaceField 470 | "R" 471 | [] 472 | 473 | 474 | syncButton = 475 | Widget.rowButton 25 SyncToSearchHit "S" [ style "float" "left" ] 476 | 477 | 478 | searchForwardButton = 479 | Widget.rowButton 30 RollSearchSelectionForward ">" [ style "float" "left" ] 480 | 481 | 482 | searchBackwardButton = 483 | Widget.rowButton 30 RollSearchSelectionBackward "<" [ style "float" "left" ] 484 | 485 | 486 | searchTextButton = 487 | Widget.rowButton 60 NoOp "Search" [ style "float" "left" ] 488 | 489 | 490 | replaceTextButton = 491 | Widget.rowButton 70 ReplaceCurrentSelection "Replace" [ style "float" "left" ] 492 | 493 | 494 | acceptLineNumber = 495 | Widget.textField 30 496 | AcceptLineNumber 497 | "" 498 | [ style "margin-top" "5px", style "float" "left" ] 499 | [ setHtmlId "line-number-input" ] 500 | 501 | 502 | acceptSearchText = 503 | Widget.textField 130 AcceptSearchText "" [ style "float" "left" ] [ setHtmlId "editor-search-box" ] 504 | 505 | 506 | acceptReplaceText = 507 | Widget.textField 130 AcceptReplacementText "" [ style "float" "left" ] [ setHtmlId "replacement-box" ] 508 | 509 | 510 | setHtmlId : String -> Html.Attribute msg 511 | setHtmlId id = 512 | Attribute.attribute "id" id 513 | 514 | 515 | showIf : Bool -> Html Msg -> Html Msg 516 | showIf flag h = 517 | if flag then 518 | h 519 | 520 | else 521 | div [] [] 522 | -------------------------------------------------------------------------------- /src/Editor.elm: -------------------------------------------------------------------------------- 1 | module Editor exposing 2 | ( embedded, init 3 | , load, update, insert 4 | , Editor, EditorConfig, EditorMsg 5 | , getBuffer, getState, getSource, getCursor, getWrapOption, getSelectedText, getFontSize, getWidth, transformConfig, lineAt, lineAtCursor 6 | , setSelectedText, setHeight, setWidth 7 | , placeInClipboard 8 | , scrollToLine, scrollToString 9 | ) 10 | 11 | {-| Use this module to embed a text editor in an Elm app. 12 | 13 | 14 | ## Contents 15 | 16 | - Embedding the Editor 17 | - Using the Editor 18 | - Types 19 | - Getters 20 | - Clipboard 21 | - Scroll 22 | 23 | 24 | ## Embedding the Editor 25 | 26 | @docs embedded, init 27 | 28 | 29 | ## Using the editor 30 | 31 | @docs load, update, insert 32 | 33 | 34 | ## Types 35 | 36 | @docs Editor, EditorConfig, EditorMsg 37 | 38 | 39 | ## Getters 40 | 41 | @docs getBuffer, getState, getSource, getCursor, getWrapOption, getSelectedText, getFontSize, getWidth, transformConfig, lineAt, lineAtCursor 42 | 43 | 44 | ## Setters 45 | 46 | @docs setSelectedText, setHeight, setWidth 47 | 48 | 49 | ## Clipboard 50 | 51 | @docs placeInClipboard 52 | 53 | 54 | ## Scroll 55 | 56 | @docs scrollToLine, scrollToString 57 | 58 | -} 59 | 60 | import Buffer exposing (Buffer) 61 | import Debounce exposing (Debounce) 62 | import Editor.Config exposing (Config, WrapOption(..), WrapParams) 63 | import Editor.History 64 | import Editor.Model exposing (InternalState) 65 | import Editor.Styles 66 | import Editor.Update 67 | import Editor.View 68 | import Editor.Wrap 69 | import Html exposing (Attribute, Html, div) 70 | import Html.Attributes as HA exposing (style) 71 | import Position exposing (Position) 72 | import RollingList 73 | 74 | 75 | {-| Example: 76 | 77 | type Msg 78 | = EditorMsg EditorMsg 79 | | LogErr String 80 | ... 81 | 82 | -} 83 | type alias EditorMsg = 84 | Editor.Update.Msg 85 | 86 | 87 | {-| Embed the editor in an app like this: 88 | 89 | type alias Model = 90 | { editor : Editor 91 | , ... 92 | } 93 | 94 | -} 95 | type Editor 96 | = Editor 97 | { buffer : Buffer 98 | , state : InternalState 99 | } 100 | 101 | 102 | {-| Get the buffer (mostly for tests) 103 | -} 104 | getBuffer : Editor -> Buffer 105 | getBuffer (Editor data) = 106 | data.buffer 107 | 108 | 109 | {-| Get the state (mostly for tests) 110 | -} 111 | getState : Editor -> InternalState 112 | getState (Editor data) = 113 | data.state 114 | 115 | 116 | 117 | -- GETTERS -- 118 | 119 | 120 | {-| -} 121 | getFontSize : Editor -> Float 122 | getFontSize (Editor data) = 123 | data.state.config.fontProportion * data.state.config.lineHeight 124 | 125 | 126 | {-| Get the options for wrapping text. See the example for `insert`. 127 | -} 128 | getWrapOption : Editor -> WrapOption 129 | getWrapOption (Editor data) = 130 | data.state.config.wrapOption 131 | 132 | 133 | {-| Return the the line at the given position 134 | -} 135 | lineAt : Position -> Editor -> Maybe String 136 | lineAt position (Editor data) = 137 | Buffer.lineAt position data.buffer 138 | 139 | 140 | {-| Return the the line at the given position 141 | -} 142 | lineAtCursor : Editor -> String 143 | lineAtCursor (Editor data) = 144 | Buffer.lineAt data.state.cursor data.buffer 145 | |> Maybe.withDefault "invalid cursor" 146 | 147 | 148 | {-| Return the current source text 149 | -} 150 | getSource : Editor -> String 151 | getSource (Editor data) = 152 | Buffer.toString data.buffer 153 | 154 | 155 | {-| Get the cursor position. See the example for `insert`. 156 | -} 157 | getCursor : Editor -> Position 158 | getCursor (Editor data) = 159 | data.state.cursor 160 | 161 | 162 | {-| -} 163 | getSelectedText : Editor -> Maybe String 164 | getSelectedText (Editor data) = 165 | data.state.selectedText 166 | 167 | 168 | {-| -} 169 | getSmallConfig : InternalState -> Config 170 | getSmallConfig s = 171 | s.config 172 | 173 | 174 | 175 | -- SETTERS -- 176 | 177 | 178 | {-| -} 179 | setSelectedText : String -> Editor -> Editor 180 | setSelectedText str (Editor data) = 181 | let 182 | is = 183 | data.state 184 | in 185 | Editor { data | state = { is | selectedText = Just str } } 186 | 187 | 188 | 189 | -- CONFIG -- 190 | 191 | 192 | {-| A typical configuration: 193 | 194 | config : EditorConfig Msg 195 | config = 196 | { editorMsg = EditorMsg 197 | , sliderMsg = SliderMsg 198 | , editorStyle = editorStyle 199 | , width = 500 200 | , lines = 30 201 | , lineHeight = 16.0 202 | , showInfoPanel = True 203 | , wrapParams = { maximumWidth = 55, optimalWidth = 50, stringWidth = String.length } 204 | , wrapOption = DontWrap 205 | } 206 | 207 | -} 208 | type alias EditorConfig a = 209 | { editorMsg : EditorMsg -> a 210 | , width : Float 211 | , height : Float 212 | , lineHeight : Float 213 | , showInfoPanel : Bool 214 | , wrapParams : { maximumWidth : Int, optimalWidth : Int, stringWidth : String -> Int } 215 | , wrapOption : WrapOption 216 | , fontProportion : Float 217 | , lineHeightFactor : Float 218 | } 219 | 220 | 221 | {-| XXX: Changed 222 | -} 223 | transformConfig : EditorConfig a -> Config 224 | transformConfig c = 225 | let 226 | fontWidth = 227 | c.fontProportion * c.lineHeight 228 | 229 | multiplier = 230 | 1.64 231 | in 232 | { --- lines = floor <| c.height / c.lineHeight 233 | showInfoPanel = c.showInfoPanel 234 | , wrapParams = { maximumWidth = floor (multiplier * c.width / fontWidth - 5), optimalWidth = floor (multiplier * c.width / fontWidth - 10), stringWidth = String.length } 235 | , wrapOption = c.wrapOption 236 | , height = c.height 237 | , width = c.width 238 | , lineHeight = c.lineHeight 239 | , fontProportion = c.fontProportion 240 | , lineHeightFactor = c.lineHeightFactor 241 | } 242 | 243 | 244 | 245 | -- EMBEDDED EDITOR -- 246 | 247 | 248 | {-| Embed the editor in the host app: 249 | 250 | view : Model -> Html Msg 251 | view model = 252 | div [ HA.style "margin" "60px" ] 253 | [ ... 254 | , Editor.embedded config model.editor 255 | , ... 256 | ] 257 | 258 | -} 259 | embedded : EditorConfig a -> Editor -> Html a 260 | embedded editorConfig editor = 261 | let 262 | styleConfig = 263 | { editorWidth = editorConfig.width 264 | , editorHeight = editorConfig.height 265 | , lineHeight = editorConfig.lineHeight 266 | , fontProportion = editorConfig.fontProportion 267 | } 268 | 269 | height_ = 270 | editorConfig.height - 37 271 | in 272 | div [] 273 | [ Editor.Styles.editorStyles styleConfig 274 | , view (innerStyle height_) editor 275 | |> Html.map editorConfig.editorMsg 276 | ] 277 | 278 | 279 | innerStyle h = 280 | [ style "height" (String.fromFloat h ++ "px") 281 | , style "border" "solid" 282 | , style "border-width" "0.5px" 283 | , style "border-color" "#aaa" 284 | , HA.attribute "id" "__inner_editor__" 285 | , style "overflow-y" "scroll" 286 | , style "height" (String.fromFloat h ++ "px") 287 | ] 288 | 289 | 290 | lines : EditorConfig msg -> Int 291 | lines editorConfig = 292 | floor <| editorConfig.height / editorConfig.lineHeight 293 | 294 | 295 | {-| XXX: Changed 296 | 297 | Initialize the embedded editor: 298 | 299 | init : () -> ( Model, Cmd Msg ) 300 | init () = 301 | ( { editor = Editor.init config AppText.jabberwocky 302 | , ... 303 | } 304 | , Cmd.none 305 | ) 306 | 307 | -} 308 | init : EditorConfig a -> String -> Editor 309 | init editorConfig text = 310 | Editor 311 | { buffer = Buffer.init text 312 | , state = 313 | { config = transformConfig editorConfig 314 | , topLine = 0 315 | , cursor = Position 0 0 316 | , selection = Nothing 317 | , selectedText = Nothing 318 | , clipboard = "" 319 | , currentLine = Nothing 320 | , dragging = False 321 | , history = Editor.History.empty 322 | , searchTerm = "" 323 | , searchHitIndex = 0 324 | , replacementText = "" 325 | , canReplace = False 326 | , searchResults = RollingList.fromList [] 327 | , showHelp = True 328 | , showInfoPanel = editorConfig.showInfoPanel 329 | , showGoToLinePanel = False 330 | , showSearchPanel = False 331 | , savedBuffer = Buffer.fromString "" 332 | , debounce = Debounce.init 333 | } 334 | } 335 | 336 | 337 | {-| Get width in pixels of editor 338 | -} 339 | getWidth : Editor -> Float 340 | getWidth (Editor data) = 341 | data.state.config.width 342 | 343 | 344 | {-| Set width of editor in pixels 345 | -} 346 | setWidth : Float -> Editor -> Editor 347 | setWidth w (Editor data) = 348 | let 349 | oldConfig = 350 | data.state.config 351 | 352 | newConfig = 353 | { oldConfig | width = w } 354 | 355 | oldState = 356 | data.state 357 | 358 | newState = 359 | { oldState | config = newConfig } 360 | in 361 | Editor { data | state = newState } 362 | 363 | 364 | {-| Set height of editor in pixels 365 | -} 366 | setHeight : Float -> Editor -> Editor 367 | setHeight h (Editor data) = 368 | let 369 | oldConfig = 370 | data.state.config 371 | 372 | newConfig = 373 | { oldConfig | height = h } 374 | 375 | oldState = 376 | data.state 377 | 378 | newState = 379 | { oldState | config = newConfig } 380 | in 381 | Editor { data | state = newState } 382 | 383 | 384 | initialState editorConfig = 385 | { config = transformConfig editorConfig 386 | , scrolledLine = 0 387 | , cursor = Position 0 0 388 | , selection = Nothing 389 | , selectedText = Nothing 390 | , clipboard = "" 391 | , currentLine = Nothing 392 | , dragging = False 393 | , history = Editor.History.empty 394 | , searchTerm = "" 395 | , replacementText = "" 396 | , canReplace = False 397 | , searchResults = RollingList.fromList [] 398 | , showHelp = True 399 | , showInfoPanel = editorConfig.showInfoPanel 400 | , showGoToLinePanel = False 401 | , showSearchPanel = False 402 | , savedBuffer = Buffer.fromString "" 403 | , debounce = Debounce.init 404 | } 405 | 406 | 407 | 408 | -- UPDATE -- 409 | 410 | 411 | {-| Respond to updates in the editor: 412 | 413 | update : Msg -> Model -> ( Model, Cmd Msg ) 414 | update msg model = 415 | case msg of 416 | EditorMsg editorMsg -> 417 | let 418 | ( editor, cmd ) = 419 | Editor.update editorMsg model.editor 420 | in 421 | ( { model | editor = editor }, Cmd.map EditorMsg cmd ) 422 | 423 | ... 424 | 425 | -} 426 | update : EditorMsg -> Editor -> ( Editor, Cmd EditorMsg ) 427 | update msg (Editor data) = 428 | let 429 | ( is, b, cmd ) = 430 | Editor.Update.update data.buffer msg data.state 431 | in 432 | ( Editor { state = is, buffer = b }, cmd ) 433 | 434 | 435 | 436 | -- VIEW -- 437 | 438 | 439 | {-| xxx 440 | -} 441 | view : List (Attribute EditorMsg) -> Editor -> Html EditorMsg 442 | view attr (Editor data) = 443 | Editor.View.view attr (Buffer.lines data.buffer) data.state 444 | 445 | 446 | 447 | -- EDITOR FUNCTIONS -- 448 | 449 | 450 | {-| xxx 451 | -} 452 | wrapSelection : Editor -> Editor 453 | wrapSelection ((Editor data) as editor) = 454 | case data.state.selection of 455 | Nothing -> 456 | editor 457 | 458 | Just sel -> 459 | let 460 | ( start, end ) = 461 | Position.order sel data.state.cursor 462 | 463 | selectedText = 464 | Buffer.between start end data.buffer 465 | 466 | wrappedText = 467 | Editor.Wrap.paragraphs data.state.config.wrapParams selectedText 468 | 469 | oldState = 470 | data.state 471 | 472 | newState = 473 | { oldState | selectedText = Just selectedText } 474 | 475 | newBuffer = 476 | Buffer.replace start end wrappedText data.buffer 477 | in 478 | Editor { state = newState, buffer = newBuffer } 479 | 480 | 481 | 482 | -- ?? -- |> recordHistory state buffer 483 | 484 | 485 | {-| Use to insert text into the editor at a given position, e.g., 486 | 487 | pasteToClipboard : Model -> String -> ( Model, Cmd msg ) 488 | pasteToClipboard model editor = 489 | ( { model 490 | | editor = 491 | Editor.insert 492 | (Editor.getWrapOption model.editor) 493 | (Editor.getCursor model.editor) 494 | editor 495 | model.editor 496 | } 497 | , Cmd.none 498 | ) 499 | 500 | -} 501 | insert : WrapOption -> Position -> String -> Editor -> Editor 502 | insert wrapOption position string (Editor data) = 503 | let 504 | textToInsert = 505 | case wrapOption of 506 | DoWrap -> 507 | Editor.Wrap.paragraphs data.state.config.wrapParams string 508 | 509 | DontWrap -> 510 | string 511 | in 512 | Editor { data | buffer = Buffer.insert position textToInsert data.buffer } 513 | 514 | 515 | {-| Place string in the editor's clipboard 516 | -} 517 | placeInClipboard : String -> Editor -> Editor 518 | placeInClipboard str (Editor data) = 519 | let 520 | oldState = 521 | data.state 522 | 523 | newState = 524 | { oldState | clipboard = str } 525 | in 526 | Editor { data | state = newState } 527 | 528 | 529 | {-| xxx 530 | -} 531 | pasteFromClipBoard : Editor -> Editor 532 | pasteFromClipBoard (Editor data) = 533 | Editor { data | buffer = Buffer.insert data.state.cursor data.state.clipboard data.buffer } 534 | 535 | 536 | {-| xxx 537 | -} 538 | clearState : Editor -> Editor 539 | clearState (Editor data) = 540 | Editor { data | state = Editor.Update.clearState data.state } 541 | 542 | 543 | {-| Load text into the embedded editor. 544 | 545 | load : WrapOption -> String -> Model -> ( Model, Cmd Msg ) 546 | load wrapOption text model = 547 | let 548 | newEditor = 549 | Editor.load wrapOption text model.editor 550 | in 551 | ( { model | editor = newEditor }, Cmd.none ) 552 | 553 | -} 554 | load : WrapOption -> String -> Editor -> Editor 555 | load wrapOption content ((Editor data) as editor) = 556 | let 557 | config = 558 | data.state.config 559 | 560 | lineLengths = 561 | String.lines content |> List.map String.length 562 | 563 | maxLineLength = 564 | List.maximum lineLengths |> Maybe.withDefault 1000 565 | 566 | buffer = 567 | if wrapOption == DoWrap && maxLineLength > config.wrapParams.maximumWidth then 568 | Buffer.fromString (Editor.Wrap.paragraphs config.wrapParams content) 569 | 570 | else 571 | Buffer.fromString content 572 | 573 | (Editor newData) = 574 | clearState editor 575 | in 576 | Editor { newData | buffer = buffer } 577 | 578 | 579 | {-| Scroll the editor to the first occurrence of a given string 580 | -} 581 | scrollToString : String -> Editor -> Editor 582 | scrollToString str (Editor data) = 583 | let 584 | ( is, b ) = 585 | Editor.Update.scrollToText str data.state data.buffer 586 | in 587 | Editor { state = is, buffer = b } 588 | 589 | 590 | {-| Scroll the editor to a given line 591 | -} 592 | scrollToLine : Int -> Editor -> Editor 593 | scrollToLine k (Editor data) = 594 | let 595 | ( is, b ) = 596 | Editor.Update.scrollToLine k data.state data.buffer 597 | in 598 | Editor { state = is, buffer = b } 599 | -------------------------------------------------------------------------------- /demo/src/Demo.elm: -------------------------------------------------------------------------------- 1 | module Demo exposing (Msg(..), main) 2 | 3 | import Browser 4 | import Browser.Dom as Dom 5 | import Cmd.Extra exposing (withCmd, withCmds, withNoCmd) 6 | import Dict exposing (Dict) 7 | import Editor exposing (Editor, EditorConfig, EditorMsg) 8 | import Editor.Config exposing (WrapOption(..)) 9 | import Editor.Update as E 10 | import Html exposing (Attribute, Html, button, div, span, text) 11 | import Html.Attributes as HA exposing (style) 12 | import Html.Events exposing (onClick) 13 | import Json.Encode as E 14 | import Markdown.ElmWithId 15 | import Markdown.Option exposing (..) 16 | import Markdown.Parse as Parse exposing (Id) 17 | import Outside 18 | import Strings 19 | import Task exposing (Task) 20 | import Time 21 | import Tree exposing (Tree) 22 | import Tree.Diff as Diff 23 | 24 | 25 | main : Program Flags Model Msg 26 | main = 27 | Browser.element 28 | { init = init 29 | , view = view 30 | , update = update 31 | , subscriptions = subscriptions 32 | } 33 | 34 | 35 | 36 | -- MSG 37 | 38 | 39 | type Msg 40 | = NoOp 41 | | EditorMsg EditorMsg 42 | | Test 43 | | GotViewport (Result Dom.Error Dom.Viewport) 44 | | Start 45 | | ElmLesson 46 | | MarkdownExample 47 | | MathExample 48 | | ChangeLog 49 | | About 50 | | Outside Outside.InfoForElm 51 | | LogErr String 52 | | SetViewPortForElement (Result Dom.Error ( Dom.Element, Dom.Viewport )) 53 | | Rerender Time.Posix 54 | 55 | 56 | documentDict : Dict String ( Msg, String ) 57 | documentDict = 58 | Dict.fromList 59 | [ ( "about", ( About, Strings.about ) ) 60 | , ( "elmLesson", ( ElmLesson, Strings.lesson ) ) 61 | , ( "changeLog", ( ChangeLog, Strings.changeLog ) ) 62 | , ( "markdownExample", ( MarkdownExample, Strings.markdownExample ) ) 63 | , ( "mathExample", ( MathExample, Strings.mathExample ) ) 64 | , ( "start", ( Start, Strings.test ) ) 65 | ] 66 | 67 | 68 | getMsgFromTitle : String -> Msg 69 | getMsgFromTitle title_ = 70 | Dict.get title_ documentDict 71 | |> Maybe.withDefault ( Start, Strings.test ) 72 | |> Tuple.first 73 | 74 | 75 | 76 | -- MODEL 77 | 78 | 79 | type alias Model = 80 | { editor : Editor 81 | , clipboard : String 82 | , message : String 83 | , sourceText : String 84 | , ast : Tree Parse.MDBlockWithId 85 | , renderedText : Html Msg 86 | , currentDocumentTitle : String 87 | , width : Float 88 | , height : Float 89 | , counter : Int 90 | , selectedId : Id 91 | } 92 | 93 | 94 | type alias Flags = 95 | { width : Float 96 | , height : Float 97 | } 98 | 99 | 100 | windowProportion = 101 | { width = 0.4 102 | , height = 0.7 103 | } 104 | 105 | 106 | px : Float -> String 107 | px k = 108 | String.fromFloat k ++ "px" 109 | 110 | 111 | init : Flags -> ( Model, Cmd Msg ) 112 | init flags = 113 | let 114 | initialText = 115 | Strings.test 116 | in 117 | ( { editor = 118 | Editor.init 119 | { config 120 | | width = windowProportion.width * flags.width 121 | , height = windowProportion.height * flags.height 122 | } 123 | Strings.test 124 | , clipboard = "" 125 | , sourceText = initialText 126 | , ast = Parse.toMDBlockTree 0 ExtendedMath initialText 127 | , renderedText = Markdown.ElmWithId.toHtml ( 0, 0 ) 0 ExtendedMath initialText 128 | , message = "ctrl-h to toggle help" 129 | , currentDocumentTitle = "start" 130 | , width = flags.width 131 | , height = flags.height 132 | , counter = 1 133 | , selectedId = ( 0, 0 ) 134 | } 135 | , Cmd.batch [ scrollEditorToTop, scrollRendredTextToTop ] 136 | ) 137 | 138 | 139 | config : EditorConfig Msg 140 | config = 141 | { editorMsg = EditorMsg 142 | , width = 450 143 | , height = 544 144 | , lineHeight = 20.0 145 | , showInfoPanel = False 146 | , wrapParams = { maximumWidth = 50, optimalWidth = 45, stringWidth = String.length } 147 | , wrapOption = DoWrap 148 | , fontProportion = 0.75 149 | , lineHeightFactor = 1.0 150 | } 151 | 152 | 153 | 154 | -- UPDATE 155 | 156 | 157 | verticalOffsetInRenderedText = 158 | 160 159 | 160 | 161 | update : Msg -> Model -> ( Model, Cmd Msg ) 162 | update msg model = 163 | case msg of 164 | NoOp -> 165 | model |> withNoCmd 166 | 167 | EditorMsg editorMsg -> 168 | let 169 | ( newEditor, editorCmd ) = 170 | Editor.update editorMsg model.editor 171 | in 172 | case editorMsg of 173 | E.CopyPasteClipboard -> 174 | let 175 | clipBoardCmd = 176 | if editorMsg == E.CopyPasteClipboard then 177 | Outside.sendInfo (Outside.AskForClipBoard E.null) 178 | 179 | else 180 | Cmd.none 181 | in 182 | model 183 | |> syncModelWithEditor newEditor 184 | |> withCmds [ clipBoardCmd, Cmd.map EditorMsg editorCmd ] 185 | 186 | E.WriteToSystemClipBoard -> 187 | ( { model | editor = newEditor }, Outside.sendInfo (Outside.WriteToClipBoard (Editor.getSelectedText newEditor |> Maybe.withDefault "Nothing!!")) ) 188 | 189 | E.Unload _ -> 190 | syncWithEditor model newEditor editorCmd 191 | 192 | E.RemoveCharAfter -> 193 | syncWithEditor model newEditor editorCmd 194 | 195 | E.RemoveCharBefore -> 196 | syncWithEditor model newEditor editorCmd 197 | 198 | E.Cut -> 199 | syncWithEditor model newEditor editorCmd 200 | 201 | E.Paste -> 202 | syncWithEditor model newEditor editorCmd 203 | 204 | E.Undo -> 205 | syncWithEditor model newEditor editorCmd 206 | 207 | E.Redo -> 208 | syncWithEditor model newEditor editorCmd 209 | 210 | E.WrapSelection -> 211 | syncWithEditor model newEditor editorCmd 212 | 213 | E.Clear -> 214 | syncWithEditor model newEditor editorCmd 215 | 216 | E.WrapAll -> 217 | syncWithEditor model newEditor editorCmd 218 | 219 | E.SendLine -> 220 | syncAndHighlightRenderedText (Editor.lineAtCursor newEditor) (Cmd.map EditorMsg editorCmd) { model | editor = newEditor } 221 | 222 | E.SyncToSearchHit -> 223 | syncAndHighlightRenderedText (Editor.lineAtCursor newEditor) (Cmd.map EditorMsg editorCmd) { model | editor = newEditor } 224 | 225 | _ -> 226 | ( { model | editor = newEditor }, Cmd.map EditorMsg editorCmd ) 227 | 228 | SetViewPortForElement result -> 229 | case result of 230 | Ok ( element, viewport ) -> 231 | ( { model | message = "synced" }, setViewPortForSelectedLineInRenderedText verticalOffsetInRenderedText element viewport ) 232 | 233 | Err _ -> 234 | ( { model | message = "sync error" }, Cmd.none ) 235 | 236 | Start -> 237 | loadDocument "start" model 238 | 239 | Test -> 240 | ( model, Dom.getViewportOf "__inner_editor__" |> Task.attempt GotViewport ) 241 | 242 | GotViewport result -> 243 | case result of 244 | Ok vp -> 245 | ( model, Cmd.none ) 246 | 247 | Err _ -> 248 | ( model, Cmd.none ) 249 | 250 | About -> 251 | loadDocument "about" model 252 | 253 | ElmLesson -> 254 | loadDocument "elmLesson" model 255 | 256 | MarkdownExample -> 257 | loadDocument "markdownExample" model 258 | 259 | MathExample -> 260 | loadDocument "mathExample" model 261 | 262 | ChangeLog -> 263 | loadDocument "changeLog" model 264 | 265 | Outside infoForElm -> 266 | case infoForElm of 267 | Outside.GotClipboard clipboard -> 268 | pasteToEditorClipboard model clipboard 269 | 270 | LogErr _ -> 271 | ( model, Cmd.none ) 272 | 273 | Rerender _ -> 274 | let 275 | newSource = 276 | Editor.getSource model.editor 277 | in 278 | { model 279 | | sourceText = newSource 280 | , ast = Parse.toMDBlockTree model.counter ExtendedMath newSource 281 | , renderedText = Markdown.ElmWithId.toHtml model.selectedId model.counter ExtendedMath newSource 282 | } 283 | |> withNoCmd 284 | 285 | 286 | 287 | -- HELPER FUNCTIONS FOR UPDATE 288 | 289 | 290 | syncModelWithEditor : Editor -> Model -> Model 291 | syncModelWithEditor editor model = 292 | let 293 | newSource = 294 | Editor.getSource editor 295 | in 296 | { model 297 | | editor = editor 298 | , counter = model.counter + 2 299 | , sourceText = newSource 300 | , ast = Parse.toMDBlockTree model.counter ExtendedMath newSource 301 | , renderedText = Markdown.ElmWithId.toHtml model.selectedId model.counter ExtendedMath newSource 302 | } 303 | 304 | 305 | syncWithEditor : Model -> Editor -> Cmd EditorMsg -> ( Model, Cmd Msg ) 306 | syncWithEditor model editor cmd = 307 | let 308 | newSource = 309 | Editor.getSource editor 310 | in 311 | ( { model 312 | | editor = editor 313 | , counter = model.counter + 2 314 | , sourceText = newSource 315 | , ast = Parse.toMDBlockTree model.counter ExtendedMath newSource 316 | , renderedText = Markdown.ElmWithId.toHtml model.selectedId model.counter ExtendedMath newSource 317 | } 318 | , Cmd.map EditorMsg cmd 319 | ) 320 | 321 | 322 | type alias IdRecord = 323 | { id : Int, version : Int } 324 | 325 | 326 | syncAndHighlightRenderedText : String -> Cmd Msg -> Model -> ( Model, Cmd Msg ) 327 | syncAndHighlightRenderedText str cmd model = 328 | let 329 | ( _, id_ ) = 330 | Parse.getId (String.trim str) (Parse.sourceMap model.ast) 331 | |> (\( s, i ) -> ( s, i |> Maybe.withDefault "i0v0" )) 332 | 333 | id = 334 | Parse.idFromString id_ |> (\( id__, version ) -> ( id__, version + 1 )) 335 | in 336 | ( processContentForHighlighting model.sourceText { model | selectedId = id } 337 | , Cmd.batch [ cmd, setViewportForElement (Parse.stringFromId id) ] 338 | ) 339 | 340 | 341 | processContentForHighlighting : String -> Model -> Model 342 | processContentForHighlighting str model = 343 | let 344 | newAst_ = 345 | Parse.toMDBlockTree model.counter ExtendedMath str 346 | 347 | newAst = 348 | Diff.mergeWith Parse.equalIds model.ast newAst_ 349 | in 350 | { model 351 | | sourceText = str 352 | 353 | -- rendering 354 | , ast = newAst 355 | , renderedText = Markdown.ElmWithId.renderHtmlWithTOC model.selectedId "Contents" newAst 356 | , counter = model.counter + 1 357 | } 358 | 359 | 360 | syncRenderedText : String -> Model -> ( Model, Cmd Msg ) 361 | syncRenderedText str_ model = 362 | let 363 | ( str, id_ ) = 364 | Parse.getId (String.trim str_) (Parse.sourceMap model.ast) 365 | in 366 | case id_ of 367 | Nothing -> 368 | ( model, Cmd.none ) 369 | 370 | Just id -> 371 | ( model, setViewportForElement id ) 372 | 373 | 374 | setViewportForElement : String -> Cmd Msg 375 | setViewportForElement id = 376 | Dom.getViewportOf "__rt_scroll__" 377 | |> Task.andThen (\vp -> getElementWithViewPort vp id) 378 | |> Task.attempt SetViewPortForElement 379 | 380 | 381 | scrollEditorToTop = 382 | scrollToTopForElement "__inner_editor__" 383 | 384 | 385 | scrollRendredTextToTop = 386 | scrollToTopForElement "__rt_scroll__" 387 | 388 | 389 | scrollToTopForElement : String -> Cmd Msg 390 | scrollToTopForElement id = 391 | Task.attempt (\_ -> NoOp) (Dom.setViewportOf id 0 0) 392 | 393 | 394 | getElementWithViewPort : Dom.Viewport -> String -> Task Dom.Error ( Dom.Element, Dom.Viewport ) 395 | getElementWithViewPort vp id = 396 | Dom.getElement id 397 | |> Task.map (\el -> ( el, vp )) 398 | 399 | 400 | setViewPortForSelectedLineInRenderedText : Float -> Dom.Element -> Dom.Viewport -> Cmd Msg 401 | setViewPortForSelectedLineInRenderedText offset element viewport = 402 | let 403 | y = 404 | viewport.viewport.y + element.element.y - verticalOffsetInRenderedText 405 | in 406 | Task.attempt (\_ -> NoOp) (Dom.setViewportOf "__rt_scroll__" 0 y) 407 | 408 | 409 | 410 | -- COPY-PASTE 411 | 412 | 413 | {-| Paste contents of clipboard into Editor 414 | -} 415 | pasteToClipboard : Model -> String -> ( Model, Cmd msg ) 416 | pasteToClipboard model str = 417 | ( { model | editor = Editor.insert (Editor.getWrapOption model.editor) (Editor.getCursor model.editor) str model.editor }, Cmd.none ) 418 | 419 | 420 | pasteToEditorClipboard : Model -> String -> ( Model, Cmd Msg ) 421 | pasteToEditorClipboard model str = 422 | let 423 | cursor = 424 | Editor.getCursor model.editor 425 | 426 | wrapOption = 427 | Editor.getWrapOption model.editor 428 | 429 | editor2 = 430 | Editor.placeInClipboard str model.editor 431 | in 432 | { model | editor = Editor.insert wrapOption cursor str editor2 } 433 | |> withCmd rerender 434 | 435 | 436 | rerender : Cmd Msg 437 | rerender = 438 | Task.perform Rerender Time.now 439 | 440 | 441 | loadDocument : String -> Model -> ( Model, Cmd Msg ) 442 | loadDocument title_ model = 443 | let 444 | ( _, content ) = 445 | Dict.get title_ documentDict |> Maybe.withDefault ( About, Strings.about ) 446 | 447 | editor = 448 | Editor.load DoWrap content model.editor 449 | 450 | ast = 451 | Parse.toMDBlockTree model.counter ExtendedMath content 452 | 453 | renderedText = 454 | Markdown.ElmWithId.toHtml model.selectedId model.counter ExtendedMath content 455 | in 456 | ( { model 457 | | counter = model.counter + 1 458 | , editor = editor 459 | , sourceText = content 460 | , ast = ast 461 | , renderedText = renderedText 462 | , currentDocumentTitle = title_ 463 | } 464 | , Cmd.batch [ scrollEditorToTop, scrollRendredTextToTop ] 465 | ) 466 | 467 | 468 | {-| Load text into Editor 469 | -} 470 | load : WrapOption -> String -> Model -> ( Model, Cmd Msg ) 471 | load wrapOption str model = 472 | let 473 | newEditor = 474 | Editor.load wrapOption str model.editor 475 | in 476 | ( { model | editor = newEditor, sourceText = str }, Cmd.none ) 477 | 478 | 479 | {-| Find str and highlight it 480 | -} 481 | highlightText : String -> Model -> ( Model, Cmd Msg ) 482 | highlightText str model = 483 | let 484 | newEditor = 485 | Editor.scrollToString str model.editor 486 | in 487 | ( { model | editor = newEditor }, Cmd.none ) 488 | 489 | 490 | 491 | -- SUBSCRIPTIONS 492 | 493 | 494 | subscriptions : Model -> Sub Msg 495 | subscriptions model = 496 | Sub.batch 497 | [ Outside.getInfo Outside LogErr 498 | ] 499 | 500 | 501 | 502 | -- VIEW 503 | 504 | 505 | view : Model -> Html Msg 506 | view model = 507 | div 508 | [ HA.style "margin-left" "30px" 509 | , HA.class "flex-column" 510 | , HA.style "width" "1200px" 511 | , HA.attribute "id" "__outer_editor__" 512 | ] 513 | [ title 514 | , div 515 | [ HA.class "flex-row" 516 | , HA.style "width" (px model.width) 517 | , HA.style "align-items" "stretch" 518 | ] 519 | [ embeddedEditor model, viewRenderedText model ] 520 | , footer model 521 | ] 522 | 523 | 524 | embeddedEditor : Model -> Html Msg 525 | embeddedEditor model = 526 | -- div [ style "width" (px <| min (maxPaneWidth + 30) (windowProportion.width * model.width + 40)) ] 527 | div [ style "width" (px <| Editor.getWidth model.editor) ] 528 | [ Editor.embedded 529 | { config 530 | | width = min maxPaneWidth (windowProportion.width * model.width + 30) 531 | , height = windowProportion.height * model.height + 24 - 20 532 | } 533 | model.editor 534 | ] 535 | 536 | 537 | maxPaneWidth = 538 | 450 539 | 540 | 541 | viewRenderedText model = 542 | div 543 | [ HA.style "flex" "row" 544 | , HA.style "width" (px <| min maxPaneWidth (windowProportion.width * model.width)) 545 | , HA.style "height" (px <| windowProportion.height * model.height - 20) 546 | , HA.style "border" "solid" 547 | , HA.style "border-color" "#444" 548 | , HA.style "border-width" "0.5px" 549 | , HA.style "overflow-y" "scroll" 550 | , HA.style "order" "1" 551 | , HA.style "align-self" "left" 552 | , HA.style "padding" "12px" 553 | , HA.attribute "id" "__rt_scroll__" 554 | ] 555 | [ model.renderedText ] 556 | 557 | 558 | title : Html Msg 559 | title = 560 | div [ HA.style "font-size" "16px", HA.style "font-style" "bold", HA.style "margin-bottom" "10px" ] 561 | [ text "-" ] 562 | 563 | 564 | footer : Model -> Html Msg 565 | footer model = 566 | div 567 | [ HA.style "font-size" "14px", HA.style "margin-top" "16px", HA.class "flex-column" ] 568 | [ div [ HA.style "margin-top" "20px", HA.class "flex-row-text-aligned" ] 569 | [ startButton model, aboutButton model, markdownExampleButton model, mathExampleButton model, elmLessonButton model, changeLogButton model, div [ style "width" "200px", messageColor model.message ] [ text model.message ] ] 570 | , div [ HA.style "margin-top" "10px" ] 571 | [ Html.a [ HA.href "https://github.com/jxxcarlson/elm-text-editor" ] [ text "Source code (Work in Progress)." ] 572 | , text "The editor in this app is based on " 573 | , Html.a [ HA.href "https://sidneynemzer.github.io/elm-text-editor/" ] 574 | [ text "work of Sydney Nemzer" ] 575 | , Html.span [] [ text " and is inspired by previous work of " ] 576 | , Html.a [ HA.href "https://discourse.elm-lang.org/t/text-editor-done-in-pure-elm/1365" ] [ text "Martin Janiczek" ] 577 | ] 578 | ] 579 | 580 | 581 | messageColor : String -> Html.Attribute msg 582 | messageColor str = 583 | case String.contains "error" str of 584 | True -> 585 | HA.style "color" "#a00" 586 | 587 | False -> 588 | HA.style "color" "#444" 589 | 590 | 591 | 592 | -- BUTTONS 593 | 594 | 595 | startButton model = 596 | rowButton model 70 Start "Start" [] 597 | 598 | 599 | testButton model = 600 | rowButton model 50 Test "Test" [] 601 | 602 | 603 | elmLessonButton model = 604 | rowButton model 50 ElmLesson "Elm" [] 605 | 606 | 607 | markdownExampleButton model = 608 | rowButton model 80 MarkdownExample "Markdown" [] 609 | 610 | 611 | mathExampleButton model = 612 | rowButton model 70 MathExample "Math" [] 613 | 614 | 615 | changeLogButton model = 616 | rowButton model 150 ChangeLog "Issues and Change Log" [] 617 | 618 | 619 | aboutButton model = 620 | rowButton model 80 About "About" [] 621 | 622 | 623 | 624 | -- STYLE -- 625 | 626 | 627 | rowButtonStyle = 628 | [ style "font-size" "12px" 629 | , style "border" "none" 630 | , style "margin-right" "8px" 631 | , style "float" "left" 632 | ] 633 | 634 | 635 | rowButtonLabelStyle width = 636 | [ style "font-size" "12px" 637 | , style "background-color" "#666" 638 | , style "color" "#eee" 639 | , style "width" (String.fromInt width ++ "px") 640 | , style "height" "24px" 641 | , style "border" "none" 642 | , style "margin-right" "10px" 643 | ] 644 | 645 | 646 | activeRowButtonLabelStyle width = 647 | [ style "font-size" "12px" 648 | , style "background-color" "#922" 649 | , style "color" "#eee" 650 | , style "width" (String.fromInt width ++ "px") 651 | , style "height" "24px" 652 | , style "border" "none" 653 | , style "margin-right" "10px" 654 | ] 655 | 656 | 657 | rowButton model width msg str attr = 658 | let 659 | style_ = 660 | case getMsgFromTitle model.currentDocumentTitle == msg of 661 | True -> 662 | activeRowButtonLabelStyle width 663 | 664 | False -> 665 | rowButtonLabelStyle width 666 | in 667 | div (rowButtonStyle ++ attr) 668 | [ button ([ onClick msg ] ++ style_) [ text str ] ] 669 | 670 | 671 | 672 | -- From Simon H: 673 | 674 | 675 | noAttr : Attribute msg 676 | noAttr = 677 | HA.classList [] 678 | 679 | 680 | {-| If the condition is true, add the attribute to the element 681 | -} 682 | attrIf : Bool -> Attribute msg -> Attribute msg 683 | attrIf bool attribute = 684 | if bool then 685 | attribute 686 | 687 | else 688 | noAttr 689 | 690 | 691 | 692 | -- ToDo: Use the below 693 | -- div [attrIf condition (style "background" "yellow")] [] 694 | --------------------------------------------------------------------------------