1
1
mirror of https://github.com/aelve/guide.git synced 2024-11-22 11:33:34 +03:00

Use Vue for (some) text editors

This commit is contained in:
Artyom Kazak 2018-09-01 14:29:32 +02:00
parent e6df42f262
commit b0d2a5e071
9 changed files with 244 additions and 63 deletions

View File

@ -47,6 +47,19 @@ $ stack test
* `favicon` code used to generate a favicon
* `guidejs` client side JavaScript
### Frontend
To lint Vue.js components, you need to do:
```bash
npm install --global eslint eslint-plugin-vue@next
cd static/components
eslint .
```
Maybe there's a better way than `npm install --global`, I don't know. I'm
not a Node.js guy.
### Notes
When you see something like

View File

@ -134,6 +134,14 @@ instance JSParams a => JSFunction (a -> JS) where
let paramList = T.intercalate "," (map fromJS (jsParams args))
in JS $ format "{}({});" fName paramList
-- This also produces a Javascript function call, but prefixes the function
-- with "this."; this is needed for event handlers in Vue for some reason
newtype WithThis a = WithThis { withThis :: a }
instance JSFunction a => JSFunction (WithThis a) where
makeJSFunction fName fParams fDef = WithThis $
makeJSFunction ("this." <> fName) fParams fDef
-- This isn't a standalone function and so it doesn't have to be listed in
-- 'allJSFunctions'.
assign :: ToJS x => JS -> x -> JS

View File

@ -126,13 +126,13 @@ hackageName _ Other = pure Other
instance A.ToJSON ItemKind where
toJSON (Library x) = A.object [
"tag" A..= ("Library" :: Text),
"tag" A..= ("Library" :: Text),
"contents" A..= x ]
toJSON (Tool x) = A.object [
"tag"   A..= ("Tool" :: Text),
"tag" A..= ("Tool" :: Text),
"contents" A..= x ]
toJSON Other = A.object [
"tag" A..= ("Other" :: Text) ]
"tag" A..= ("Other" :: Text) ]
data ItemKind_v2
= Library_v2 (Maybe Text)

View File

@ -643,6 +643,9 @@ wrapPage pageTitle' page = doctypehtml_ $ do
return false; };
|]
includeJS "/js/bundle.js"
-- TODO: don't use development build in production!
includeJS "https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"
includeJS "/components/AEditor.js"
-- for modal dialogs
includeJS "/magnific-popup.js"
includeCSS "/magnific-popup.css"

View File

@ -179,9 +179,9 @@ renderCategoryNotes category = cached (CacheCategoryNotes (category^.uid)) $ do
T.readFile "static/category-notes-template.md"
else return (category^.notes)
markdownEditor
[rows_ "10", class_ " editor "]
10 -- rows
contents
(\val -> JS.submitCategoryNotes
(this, category^.uid, category^.notes.mdSource, val))
(JS.switchSection (this, "normal" :: Text))
(\val -> JS.withThis JS.submitCategoryNotes
(this, category^.uid, category^.notes.mdSource, val))
(JS.withThis JS.switchSection (this, "normal" :: Text))
"or press Ctrl+Enter to save"

View File

@ -147,7 +147,7 @@ renderItemDescription item = cached (CacheItemDescription (item^.uid)) $
mustache "item-description" $ A.object [
"item" A..= item ]
-- | Render the “ecosystem” secion..
-- | Render the “ecosystem” section.
renderItemEcosystem :: MonadIO m => Item -> HtmlT m ()
renderItemEcosystem item = cached (CacheItemEcosystem (item^.uid)) $ do
let thisId = "item-ecosystem-" <> uidToText (item^.uid)
@ -174,11 +174,11 @@ renderItemEcosystem item = cached (CacheItemEcosystem (item^.uid)) $ do
[style_ "width:12px;opacity:0.5", class_ " edit-item-ecosystem "] $
JS.switchSection (this, "normal" :: Text)
markdownEditor
[rows_ "3", class_ " editor "]
3 -- rows
(item^.ecosystem)
(\val -> JS.submitItemEcosystem
(this, item^.uid, item^.ecosystem.mdSource, val))
(JS.switchSection (this, "normal" :: Text))
(\val -> JS.withThis JS.submitItemEcosystem
(this, item^.uid, item^.ecosystem.mdSource, val))
(JS.withThis JS.switchSection (this, "normal" :: Text))
"or press Ctrl+Enter to save"
-- | Render the “traits” section.
@ -205,12 +205,14 @@ renderItemTraits item = cached (CacheItemTraits (item^.uid)) $ do
mapM_ (renderTrait (item^.uid)) (item^.pros)
section "editable" [] $ do
smallMarkdownEditor
[rows_ "3", placeholder_ "add pro"]
3 -- rows
(toMarkdownInline "")
(\val -> JS.addPro (JS.selectUid listUid, item^.uid, val) <>
JS.assign val ("" :: Text))
-- TODO: clearing the editor should be moved into 'addPro' and
-- done only if the request succeeds
(\val -> JS.withThis JS.addPro (JS.selectUid listUid, item^.uid, val))
Nothing
"press Ctrl+Enter or Enter to add"
(Just "add pro") -- placeholder
textButton "edit off" $
JS.switchSectionsEverywhere(this, "normal" :: Text)
@ -231,12 +233,14 @@ renderItemTraits item = cached (CacheItemTraits (item^.uid)) $ do
mapM_ (renderTrait (item^.uid)) (item^.cons)
section "editable" [] $ do
smallMarkdownEditor
[rows_ "3", placeholder_ "add con"]
3 -- rows
(toMarkdownInline "")
(\val -> JS.addCon (JS.selectUid listUid, item^.uid, val) <>
JS.assign val ("" :: Text))
-- TODO: clearing the editor should be moved into 'addCon' and
-- done only if the request succeeds
(\val -> JS.withThis JS.addCon (JS.selectUid listUid, item^.uid, val))
Nothing
"press Ctrl+Enter or Enter to add"
(Just "add con") -- placeholder
textButton "edit off" $
JS.switchSectionsEverywhere(this, "normal" :: Text)

View File

@ -75,6 +75,7 @@ import Text.Digestive (View)
-- import NeatInterpolation
-- Web
import Lucid hiding (for_)
import Lucid.Base (makeAttribute)
-- Files
import qualified System.FilePath.Find as F
-- -- Network
@ -179,64 +180,59 @@ checkedIf p x = if p then with x [checked_] else x
hiddenIf :: With w => Bool -> w -> w
hiddenIf p x = if p then with x [style_ "display:none;"] else x
-- | @v-bind@ from Vue.js
vBind :: JS.ToJS a => Text -> a -> Attribute
vBind x val = makeAttribute (":" <> x) (fromJS (JS.toJS val))
-- | @v-on@ from Vue.js
--
-- You can access the event payload with @$event@.
vOn :: Text -> JS -> Attribute
vOn x js = makeAttribute ("@" <> x) (fromJS js)
markdownEditor
:: MonadIO m
=> [Attribute]
=> Int -- ^ How many rows the editor should have
-> MarkdownBlock -- ^ Default text
-> (JS -> JS) -- ^ “Submit” handler, receiving the contents of the editor
-> (JS -> JS) -- ^ “Submit” handler, receiving a variable with the
-- contents of the editor
-> JS -- ^ “Cancel” handler
-> Text -- ^ Instruction (e.g. “press Ctrl+Enter to save”)
-> HtmlT m ()
markdownEditor attr (view mdSource -> s) submit cancel instr = do
textareaUid <- randomLongUid
let val = JS $ "document.getElementById(\""+|textareaUid|+"\").value"
-- Autocomplete has to be turned off thanks to
-- <http://stackoverflow.com/q/8311455>.
textarea_ ([uid_ textareaUid,
autocomplete_ "off",
class_ "big fullwidth",
onCtrlEnter (submit val),
onEscape (JS.assign val s <> cancel) ]
++ attr) $
toHtml s
button "Save" [class_ " save "] $
submit val
emptySpan "6px"
button "Cancel" [class_ " cancel "] $
JS.assign val s <>
cancel
emptySpan "6px"
span_ [class_ "edit-field-instruction"] (toHtml instr)
a_ [href_ "/markdown", target_ "_blank"] $
img_ [src_ "/markdown.svg", alt_ "markdown supported",
class_ " markdown-supported "]
markdownEditor rows (view mdSource -> src) submit cancel instr = do
editorUid <- randomLongUid
term "a-editor" [uid_ editorUid,
vBind "init-content" src,
vBind "instruction" instr,
vBind "rows" rows,
vOn "submit-edit" (submit (JS "$event")),
vOn "cancel-edit" cancel]
(pure ())
script_ (format "new Vue({el: '#{}'});" editorUid)
smallMarkdownEditor
:: MonadIO m
=> [Attribute]
=> Int -- ^ How many rows the editor should have
-> MarkdownInline -- ^ Default text
-> (JS -> JS) -- ^ “Submit” handler, receiving the contents of the editor
-> (JS -> JS) -- ^ “Submit” handler, receiving a variable with the
-- contents of the editor
-> Maybe JS -- ^ “Cancel” handler (if “Cancel” is needed)
-> Text -- ^ Instruction (e.g. “press Enter to add”)
-> Maybe Text -- ^ Placeholder
-> HtmlT m ()
smallMarkdownEditor attr (view mdSource -> s) submit mbCancel instr = do
textareaId <- randomLongUid
let val = JS $ "document.getElementById(\""+|textareaId|+"\").value"
textarea_ ([class_ "fullwidth", uid_ textareaId, autocomplete_ "off"] ++
[onEnter (submit val)] ++
[onEscape cancel | Just cancel <- [mbCancel]] ++
attr) $
toHtml s
br_ []
for_ mbCancel $ \cancel -> do
textButton "cancel" $
JS.assign val s <>
cancel
span_ [style_ "float:right"] $ do
span_ [class_ "edit-field-instruction"] (toHtml instr)
a_ [href_ "/markdown", target_ "_blank"] $
img_ [src_ "/markdown.svg", alt_ "markdown supported",
class_ " markdown-supported "]
smallMarkdownEditor rows (view mdSource -> src) submit mbCancel instr mbPlaceholder = do
editorUid <- randomLongUid
term "a-editor-mini" ([uid_ editorUid,
vBind "init-content" src,
vBind "instruction" instr,
vBind "rows" rows] ++
map (vBind "placeholder") (maybeToList mbPlaceholder) ++
[vOn "submit-edit" (submit (JS "$event"))] ++
case mbCancel of {
Nothing -> [vBind "allow-cancel" False];
Just cancel -> [vOn "cancel-edit" cancel]; })
(pure ())
script_ (format "new Vue({el: '#{}'});" editorUid)
thisNode :: MonadIO m => HtmlT m JQuerySelector
thisNode = do

View File

@ -0,0 +1,11 @@
module.exports = {
extends: ['eslint:recommended', 'plugin:vue/recommended'],
'env': {
'es6': true,
'jquery': true,
},
plugins: ['vue'],
globals: {
'Vue': true,
},
};

View File

@ -0,0 +1,146 @@
////////////////////////////////////////////////////////////////////////////
// A multiline text editor with "Submit" and "Cancel" buttons.
//
// Available keybindings:
// * Ctrl+Enter Submit
// * Escape Cancel
//
// Events:
// * submit-edit(String) Some text should be saved
// * cancel-edit The edit has been cancelled
//
////////////////////////////////////////////////////////////////////////////
// TODO: check that it's okay that editors have been wrapped into <div>
// TODO: I can recall 'section' being removed from item-info, maybe that's important
Vue.component('AEditor', {
props: {
// Text to populate the editor with
initContent: {type: String, default: ""},
// Instruction for the user
instruction: {type: String, required: true},
// How many rows the editor should have
rows: {type: Number, required: true},
// Whether editor content should be reset on cancel
resetOnCancel: {type: Boolean, default: true},
},
data: function() { return {
content: this.initContent,
}},
methods: {
onSubmitEdit() {
this.$emit('submit-edit', this.content); },
onCancelEdit() {
if (this.resetOnCancel) this.content = this.initContent;
this.$emit('cancel-edit'); },
},
// TODO: another messy template; also, can use <button> instead of <input>;
// also, "editor" should be changed to "editor-area" or something, and we
// should provide a method to focus the editor area
//
// Autocomplete has to be turned off because of this issue:
// http://stackoverflow.com/q/8311455
//
// We also have to check for keycode 10 to handle Ctrl+Enter thanks to this
// Chrome bug: https://bugs.chromium.org/p/chromium/issues/detail?id=79407
template: `
<div>
<textarea
v-model="content"
autocomplete="off"
class="big fullwidth editor"
:rows="rows"
@keydown.ctrl.enter.prevent="onSubmitEdit" @keydown.ctrl.10.prevent="onSubmitEdit"
@keydown.meta.enter.prevent="onSubmitEdit" @keydown.meta.10.prevent="onSubmitEdit"
@keydown.esc.prevent="onCancelEdit"
></textarea>
<input type="button" value="Save" class="save" @click="onSubmitEdit">
<span style="margin-left:6px"></span>
<input type="button" value="Cancel" class="cancel" @click="onCancelEdit">
<span style="margin-left:6px"></span>
<span class="edit-field-instruction">{{ instruction }}</span>
<a href="/markdown" target="_blank">
<img class="markdown-supported"
src="/markdown.svg" alt="Markdown supported">
</a>
</div>
`
});
////////////////////////////////////////////////////////////////////////////
// A single-line editor with an optional "Cancel" button.
//
// Available keybindings:
// * Enter Submit
// * Escape Cancel
//
// Events:
// * submit-edit(String) Some text should be saved
// * cancel-edit The edit has been cancelled
//
////////////////////////////////////////////////////////////////////////////
Vue.component('AEditorMini', {
props: {
// Text to populate the editor with
initContent: {type: String, default: ""},
// Instruction for the user
instruction: {type: String, required: true},
// How many rows the editor should have
rows: {type: Number, required: true},
// Whether editor content should be reset on cancel
resetOnCancel: {type: Boolean, default: true},
// Whether to allow cancellation
allowCancel: {type: Boolean, default: true},
// Placeholder
placeholder: {type: String, default: ""},
},
data: function() { return {
content: this.initContent,
}},
methods: {
onSubmitEdit: function() {
this.$emit('submit-edit', this.content);
// TODO: we should have a separate method for that and only call it
// if the event handler has succeeded (outside of this component)
this.content = ""; },
onCancelEdit: function() {
if (this.allowCancel) {
if (this.resetOnCancel) this.content = this.initContent;
this.$emit('cancel-edit'); } },
},
template: `
<div>
<textarea
v-model="content"
autocomplete="off"
class="fullwidth"
:rows="rows"
:placeholder="placeholder"
@keydown.enter.prevent="onSubmitEdit"
@keydown.esc.prevent="onCancelEdit"
></textarea>
<br>
<span v-if="allowCancel" class="text-button">
<a href="#" @click.prevent="onCancelEdit">cancel</a>
</span>
<span style="float:right">
<span class="edit-field-instruction">{{ instruction }}</span>
<a href="/markdown" target="_blank">
<img class="markdown-supported"
src="/markdown.svg" alt="Markdown supported">
</a>
</span>
</div>
`
});