mirror of
https://github.com/aelve/guide.git
synced 2024-11-22 20:01:36 +03:00
Use Vue for (some) text editors
This commit is contained in:
parent
e6df42f262
commit
b0d2a5e071
13
README.md
13
README.md
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
11
static/components/.eslintrc.js
Normal file
11
static/components/.eslintrc.js
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
extends: ['eslint:recommended', 'plugin:vue/recommended'],
|
||||
'env': {
|
||||
'es6': true,
|
||||
'jquery': true,
|
||||
},
|
||||
plugins: ['vue'],
|
||||
globals: {
|
||||
'Vue': true,
|
||||
},
|
||||
};
|
146
static/components/AEditor.js
Normal file
146
static/components/AEditor.js
Normal 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>
|
||||
`
|
||||
});
|
Loading…
Reference in New Issue
Block a user