diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 890c1420..2ce28375 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -275,4 +275,8 @@ svg { white-space: pre-wrap; } } + + .sutty-editor-loading-image { + opacity: .5; + } } diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 20f2951f..f0f75a9e 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -38,7 +38,7 @@ import {toggleMark, setBlockType, wrapIn, baseKeymap} from "prosemirror-commands import {toggleWrap, toggleBlockType, toggleList, updateMark, removeMark} from "tiptap-commands" -import { DirectUpload } from "@rails/activestorage" +import {startImageUpload, placeholderPlugin} from "./imageUpload" const toggleBold = toggleMark(schema.marks.strong) const toggleItalic = toggleMark(schema.marks.em) @@ -102,6 +102,19 @@ class SuttyEditor { setupBtn('ol', () => this.runCommand(toggleOl)) setupBtn('h1', () => this.runCommand(toggleH1)) setupBtn('h2', () => this.runCommand(toggleH2)) + + const imgInput = el.querySelector('.img-input') + imgInput.addEventListener('change', event => { + if ( + this.view.state.selection.$from.parent.inlineContent + && event.target.files.length + ) { + const file = event.target.files[0] + event.target.value = '' + startImageUpload(this.view, file, schema) + } + this.view.focus() + }) } buildState (markdown) { @@ -113,6 +126,7 @@ class SuttyEditor { plugins: [ gapCursor(), keymap(baseKeymap), + placeholderPlugin, inputRules({ rules: [ //emDash, // -- => — diff --git a/app/javascript/packs/imageUpload.js b/app/javascript/packs/imageUpload.js new file mode 100644 index 00000000..a88eed03 --- /dev/null +++ b/app/javascript/packs/imageUpload.js @@ -0,0 +1,104 @@ +import { DirectUpload } from "@rails/activestorage" + +import {Plugin} from "prosemirror-state" +import {Decoration, DecorationSet} from "prosemirror-view" + +let placeholderPlugin = new Plugin({ + state: { + init() { return DecorationSet.empty }, + apply(tr, set) { + // Adjust decoration positions to changes made by the transaction + set = set.map(tr.mapping, tr.doc) + // See if the transaction adds or removes any placeholders + let action = tr.getMeta(this) + if (action && action.add) { + let widget = document.createElement( + action.add.blobUrl ? 'img' : 'placeholder' + ) + + // mostrar imágen en cache mientras tanto + if (action.add.blobUrl) { + widget.src = action.add.blobUrl + widget.classList.add('sutty-editor-loading-image') + } + + let deco = Decoration.widget(action.add.pos, widget, {id: action.add.id}) + set = set.add(tr.doc, [deco]) + } else if (action && action.remove) { + set = set.remove( + set.find( + null, null, + spec => spec.id == action.remove.id, + ), + ) + } + return set + } + }, + props: { + decorations(state) { return this.getState(state) } + } +}) +export {placeholderPlugin} + +function findPlaceholder(state, id) { + let decos = placeholderPlugin.getState(state) + let found = decos.find(null, null, spec => spec.id == id) + return found.length ? found[0].from : null +} + +// XXX: buscar una manera mejor de pasar el schema +export function startImageUpload (view, file, schema) { + const altText = window.prompt('descripción de imágen', '') + + let id = {} + + const blobUrl = URL.createObjectURL(file) + + // Replace the selection with a placeholder + let tr = view.state.tr + if (!tr.selection.empty) tr.deleteSelection() + tr.setMeta(placeholderPlugin, { + add: {id, pos: tr.selection.from, blobUrl}, + }) + view.dispatch(tr) + + uploadFile(file).then(url => { + let pos = findPlaceholder(view.state, id) + // If the content around the placeholder has been deleted, drop + // the image + if (pos == null) return + + // Otherwise, insert it at the placeholder's position, and remove + // the placeholder + view.dispatch( + view.state.tr + .replaceWith(pos, pos, schema.nodes.image.create({ + src: url, + alt: altText.length ? altText : undefined, + })) + .setMeta(placeholderPlugin, {remove: {id}}) + ) + }, () => { + // On failure, just clean up the placeholder + view.dispatch(tr.setMeta(placeholderPlugin, {remove: {id}})) + }) +} + +export function uploadFile (file) { + return new Promise((resolve, reject) => { + const upload = new DirectUpload( + file, + location.origin + '/rails/active_storage/direct_uploads', + ) + + upload.create((error, blob) => { + if (error) { + reject(error) + } else { + const url = `${location.origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}` + resolve(url) + } + }) + }) +} diff --git a/app/views/application/_editor.haml b/app/views/application/_editor.haml index f20aa204..75922efd 100644 --- a/app/views/application/_editor.haml +++ b/app/views/application/_editor.haml @@ -6,7 +6,7 @@ %button.ul-btn= "ul" %button.ol-btn= "ol" %button.link-btn= "link" - %button.img-btn= "image" + %input.img-input{ type: 'file', accept: 'image/png, image/jpeg' } %button.h1-btn= "h1" %button.h2-btn= "h2" .sutty-editor.sutty-editor-prosemirror.content{ 'data-sutty-identifier': identifier }