fix(ux): Images in Markdownversion-14
@@ -0,0 +1,22 @@ | |||
context("Control Markdown Editor", () => { | |||
before(() => { | |||
cy.login(); | |||
cy.visit("/app"); | |||
}); | |||
it("should allow inserting images by drag and drop", () => { | |||
cy.visit("/app/web-page/new"); | |||
cy.fill_field("content_type", "Markdown", "Select"); | |||
cy.get_field("main_section_md", "Markdown Editor").attachFile( | |||
"sample_image.jpg", | |||
{ | |||
subjectType: "drag-n-drop" | |||
} | |||
); | |||
cy.click_modal_primary_button("Upload"); | |||
cy.get_field("main_section_md", "Markdown Editor").should( | |||
"contain", | |||
"" | |||
); | |||
}); | |||
}); |
@@ -174,6 +174,9 @@ Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => { | |||
if (fieldtype === 'Code') { | |||
selector = `[data-fieldname="${fieldname}"] .ace_text-input`; | |||
} | |||
if (fieldtype === 'Markdown Editor') { | |||
selector = `[data-fieldname="${fieldname}"] .ace-editor-target`; | |||
} | |||
return cy.get(selector).first(); | |||
}); | |||
@@ -36,7 +36,7 @@ | |||
ref="file_input" | |||
@change="on_file_input" | |||
:multiple="allow_multiple" | |||
:accept="restrictions.allowed_file_types.join(', ')" | |||
:accept="(restrictions.allowed_file_types || []).join(', ')" | |||
> | |||
<button class="btn btn-file-upload" v-if="!disable_file_browser" @click="show_file_browser = true"> | |||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> | |||
@@ -108,9 +108,9 @@ | |||
</div> | |||
</div> | |||
<ImageCropper | |||
v-if="show_image_cropper" | |||
v-if="show_image_cropper && wrapper_ready" | |||
:file="files[crop_image_with_index]" | |||
:attach_doc_image="attach_doc_image" | |||
:fixed_aspect_ratio="restrictions.crop_image_aspect_ratio" | |||
@toggle_image_cropper="toggle_image_cropper(-1)" | |||
@upload_after_crop="trigger_upload=true" | |||
/> | |||
@@ -171,7 +171,8 @@ export default { | |||
default: () => ({ | |||
max_file_size: null, // 2048 -> 2KB | |||
max_number_of_files: null, | |||
allowed_file_types: [] // ['image/*', 'video/*', '.jpg', '.gif', '.pdf'] | |||
allowed_file_types: [], // ['image/*', 'video/*', '.jpg', '.gif', '.pdf'], | |||
crop_image_aspect_ratio: null // 1, 16 / 9, 4 / 3, NaN (free) | |||
}) | |||
}, | |||
attach_doc_image: { | |||
@@ -203,7 +204,8 @@ export default { | |||
allow_web_link: true, | |||
google_drive_settings: { | |||
enabled: false | |||
} | |||
}, | |||
wrapper_ready: false | |||
} | |||
}, | |||
created() { | |||
@@ -286,11 +288,12 @@ export default { | |||
.filter(this.check_restrictions) | |||
.map(file => { | |||
let is_image = file.type.startsWith('image'); | |||
let size_kb = file.size / 1024; | |||
return { | |||
file_obj: file, | |||
cropper_file: file, | |||
crop_box_data: null, | |||
optimize: this.attach_doc_image ? true : false, | |||
optimize: size_kb > 200 && is_image && !file.type.includes('svg'), | |||
name: file.name, | |||
doc: null, | |||
progress: 0, | |||
@@ -303,12 +306,15 @@ export default { | |||
} | |||
}); | |||
this.files = this.files.concat(files); | |||
if(this.files.length != 0 && this.attach_doc_image) { | |||
this.toggle_image_cropper(0); | |||
// if only one file is allowed and crop_image_aspect_ratio is set, open cropper immediately | |||
if (this.files.length === 1 && !this.allow_multiple && this.restrictions.crop_image_aspect_ratio != null) { | |||
if (!this.files[0].file_obj.type.includes('svg')) { | |||
this.toggle_image_cropper(0); | |||
} | |||
} | |||
}, | |||
check_restrictions(file) { | |||
let { max_file_size, allowed_file_types } = this.restrictions; | |||
let { max_file_size, allowed_file_types = [] } = this.restrictions; | |||
let mime_type = file.type; | |||
let extension = '.' + file.name.split('.').pop(); | |||
@@ -1,12 +1,39 @@ | |||
<template> | |||
<div> | |||
<div> | |||
<img ref="image" :src="src" :alt="file.name"/> | |||
<img ref="image" :src="src" :alt="file.name" /> | |||
</div> | |||
<br/> | |||
<div class="image-cropper-actions"> | |||
<button class="btn btn-sm margin-right" v-if="!attach_doc_image" @click="$emit('toggle_image_cropper')">Back</button> | |||
<button class="btn btn-primary btn-sm margin-right" @click="crop_image" v-html="crop_button_text"></button> | |||
<div> | |||
<div class="btn-group" v-if="fixed_aspect_ratio == null"> | |||
<button | |||
v-for="button in aspect_ratio_buttons" | |||
type="button" | |||
class="btn btn-default btn-sm" | |||
:class="{ | |||
active: isNaN(aspect_ratio) | |||
? isNaN(button.value) | |||
: button.value === aspect_ratio | |||
}" | |||
:key="button.label" | |||
@click="aspect_ratio = button.value" | |||
> | |||
{{ button.label }} | |||
</button> | |||
</div> | |||
</div> | |||
<div> | |||
<button | |||
class="btn btn-sm margin-right" | |||
@click="$emit('toggle_image_cropper')" | |||
v-if="fixed_aspect_ratio == null" | |||
> | |||
{{ __("Back") }} | |||
</button> | |||
<button class="btn btn-primary btn-sm" @click="crop_image"> | |||
{{ __("Crop") }} | |||
</button> | |||
</div> | |||
</div> | |||
</div> | |||
</template> | |||
@@ -15,22 +42,31 @@ | |||
import Cropper from "cropperjs"; | |||
export default { | |||
name: "ImageCropper", | |||
props: ["file", "attach_doc_image"], | |||
props: ["file", "fixed_aspect_ratio"], | |||
data() { | |||
let aspect_ratio = | |||
this.fixed_aspect_ratio != null ? this.fixed_aspect_ratio : NaN; | |||
return { | |||
src: null, | |||
cropper: null, | |||
image: null | |||
image: null, | |||
aspect_ratio | |||
}; | |||
}, | |||
watch: { | |||
aspect_ratio(value) { | |||
if (this.cropper) { | |||
this.cropper.setAspectRatio(value); | |||
} | |||
} | |||
}, | |||
mounted() { | |||
if (window.FileReader) { | |||
let fr = new FileReader(); | |||
fr.onload = () => (this.src = fr.result); | |||
fr.readAsDataURL(this.file.cropper_file); | |||
} | |||
aspect_ratio = this.attach_doc_image ? 1 : NaN; | |||
crop_box = this.file.crop_box_data; | |||
let crop_box = this.file.crop_box_data; | |||
this.image = this.$refs.image; | |||
this.image.onload = () => { | |||
this.cropper = new Cropper(this.image, { | |||
@@ -38,13 +74,31 @@ export default { | |||
scalable: false, | |||
viewMode: 1, | |||
data: crop_box, | |||
aspectRatio: aspect_ratio | |||
aspectRatio: this.aspect_ratio | |||
}); | |||
window.cropper = this.cropper; | |||
}; | |||
}, | |||
computed: { | |||
crop_button_text() { | |||
return this.attach_doc_image ? "Upload" : "Crop"; | |||
aspect_ratio_buttons() { | |||
return [ | |||
{ | |||
label: __("1:1"), | |||
value: 1 | |||
}, | |||
{ | |||
label: __("4:3"), | |||
value: 4 / 3 | |||
}, | |||
{ | |||
label: __("16:9"), | |||
value: 16 / 9 | |||
}, | |||
{ | |||
label: __("Free"), | |||
value: NaN | |||
} | |||
]; | |||
} | |||
}, | |||
methods: { | |||
@@ -58,9 +112,6 @@ export default { | |||
}); | |||
this.file.file_obj = cropped_file_obj; | |||
this.$emit("toggle_image_cropper"); | |||
if(this.attach_doc_image) { | |||
this.$emit("upload_after_crop"); | |||
} | |||
}, file_type); | |||
} | |||
} | |||
@@ -75,6 +126,8 @@ img { | |||
.image-cropper-actions { | |||
display: flex; | |||
justify-content: flex-end; | |||
align-items: center; | |||
justify-content: space-between; | |||
margin-top: var(--margin-md); | |||
} | |||
</style> |
@@ -10,11 +10,12 @@ export default class FileUploader { | |||
fieldname, | |||
files, | |||
folder, | |||
restrictions, | |||
restrictions = {}, | |||
upload_notes, | |||
allow_multiple, | |||
as_dataurl, | |||
disable_file_browser, | |||
dialog_title, | |||
attach_doc_image, | |||
frm | |||
} = {}) { | |||
@@ -22,15 +23,11 @@ export default class FileUploader { | |||
frm && frm.attachments.max_reached(true); | |||
if (!wrapper) { | |||
this.make_dialog(); | |||
this.make_dialog(dialog_title); | |||
} else { | |||
this.wrapper = wrapper.get ? wrapper.get(0) : wrapper; | |||
} | |||
if (attach_doc_image) { | |||
restrictions.allowed_file_types = ['image/jpeg', 'image/png']; | |||
} | |||
this.$fileuploader = new Vue({ | |||
el: this.wrapper, | |||
render: h => h(FileUploaderComponent, { | |||
@@ -54,6 +51,10 @@ export default class FileUploader { | |||
this.uploader = this.$fileuploader.$children[0]; | |||
if (!this.dialog) { | |||
this.uploader.wrapper_ready = true; | |||
} | |||
this.uploader.$watch('files', (files) => { | |||
let all_private = files.every(file => file.private); | |||
if (this.dialog) { | |||
@@ -94,14 +95,17 @@ export default class FileUploader { | |||
return this.uploader.upload_files(); | |||
} | |||
make_dialog() { | |||
make_dialog(title) { | |||
this.dialog = new frappe.ui.Dialog({ | |||
title: __('Upload'), | |||
title: title || __('Upload'), | |||
primary_action_label: __('Upload'), | |||
primary_action: () => this.upload_files(), | |||
secondary_action_label: __('Set all private'), | |||
secondary_action: () => { | |||
this.uploader.toggle_all_private(); | |||
}, | |||
on_page_show: () => { | |||
this.uploader.wrapper_ready = true; | |||
} | |||
}); | |||
@@ -61,7 +61,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro | |||
} | |||
on_attach_doc_image() { | |||
this.set_upload_options(); | |||
this.upload_options["attach_doc_image"] = true; | |||
this.upload_options.restrictions.allowed_file_types = ['image/*']; | |||
this.upload_options.restrictions.crop_image_aspect_ratio = 1; | |||
this.file_uploader = new frappe.ui.FileUploader(this.upload_options); | |||
} | |||
set_upload_options() { | |||
@@ -70,7 +71,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro | |||
on_success: file => { | |||
this.on_upload_complete(file); | |||
this.toggle_reload_button(); | |||
} | |||
}, | |||
restrictions: {} | |||
}; | |||
if (this.frm) { | |||
@@ -19,7 +19,6 @@ frappe.ui.form.ControlAttachImage = class ControlAttachImage extends frappe.ui.f | |||
} | |||
set_upload_options() { | |||
super.set_upload_options(); | |||
this.upload_options.restrictions = {}; | |||
this.upload_options.restrictions.allowed_file_types = ['image/*']; | |||
} | |||
}; |
@@ -54,17 +54,57 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex | |||
return this._autocompletions || []; | |||
}, | |||
set: (value) => { | |||
let getter = value; | |||
if (typeof getter !== 'function') { | |||
getter = () => value; | |||
} | |||
if (!this._autocompletions) { | |||
this._autocompletions = []; | |||
} | |||
this._autocompletions.push(getter); | |||
this.setup_autocompletion(); | |||
this.df._autocompletions = value; | |||
} | |||
}); | |||
} | |||
setup_autocompletion() { | |||
setup_autocompletion(customGetCompletions) { | |||
if (this._autocompletion_setup) return; | |||
const ace = window.ace; | |||
const get_autocompletions = () => this.df.autocompletions; | |||
let getCompletions = (editor, session, pos, prefix, callback) => { | |||
if (prefix.length === 0) { | |||
callback(null, []); | |||
return; | |||
} | |||
const get_autocompletions = () => { | |||
let getters = this._autocompletions || []; | |||
let completions = []; | |||
for (let getter of getters) { | |||
let values = getter({ editor, session, pos, prefix }); | |||
completions.push(...values); | |||
} | |||
return completions; | |||
}; | |||
let autocompletions = get_autocompletions(); | |||
if (autocompletions.length) { | |||
callback( | |||
null, | |||
autocompletions.map(a => { | |||
if (typeof a === "string") { | |||
a = { value: a }; | |||
} | |||
return { | |||
name: "frappe", | |||
value: a.value, | |||
score: a.score, | |||
meta: a.meta, | |||
caption: a.caption | |||
}; | |||
}) | |||
); | |||
} | |||
}; | |||
ace.config.loadModule("ace/ext/language_tools", langTools => { | |||
this.editor.setOptions({ | |||
@@ -73,28 +113,7 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex | |||
}); | |||
langTools.addCompleter({ | |||
getCompletions: function(editor, session, pos, prefix, callback) { | |||
if (prefix.length === 0) { | |||
callback(null, []); | |||
return; | |||
} | |||
let autocompletions = get_autocompletions(); | |||
if (autocompletions.length) { | |||
callback( | |||
null, | |||
autocompletions.map(a => { | |||
if (typeof a === 'string') { | |||
a = { value: a }; | |||
} | |||
return { | |||
name: 'frappe', | |||
value: a.value, | |||
score: a.score | |||
}; | |||
}) | |||
); | |||
} | |||
} | |||
getCompletions: customGetCompletions || getCompletions | |||
}); | |||
}); | |||
this._autocompletion_setup = true; | |||
@@ -29,6 +29,8 @@ frappe.ui.form.ControlMarkdownEditor = class ControlMarkdownEditor extends frapp | |||
this.markdown_preview = $(`<div class="${editor_class}-preview border rounded">`).hide(); | |||
this.markdown_container.append(this.markdown_preview); | |||
this.setup_image_drop(); | |||
} | |||
set_language() { | |||
@@ -53,4 +55,45 @@ frappe.ui.form.ControlMarkdownEditor = class ControlMarkdownEditor extends frapp | |||
set_disp_area(value) { | |||
this.disp_area && $(this.disp_area).text(value); | |||
} | |||
setup_image_drop() { | |||
this.ace_editor_target.on('drop', e => { | |||
e.stopPropagation(); | |||
e.preventDefault(); | |||
let { dataTransfer } = e.originalEvent; | |||
if (!dataTransfer?.files?.length) { | |||
return; | |||
} | |||
let files = dataTransfer.files; | |||
if (!files[0].type.includes('image')) { | |||
frappe.show_alert({ | |||
message: __('You can only insert images in Markdown fields', [files[0].name]), | |||
indicator: 'orange' | |||
}); | |||
return; | |||
} | |||
new frappe.ui.FileUploader({ | |||
dialog_title: __('Insert Image in Markdown'), | |||
doctype: this.doctype, | |||
docname: this.docname, | |||
frm: this.frm, | |||
files, | |||
folder: 'Home/Attachments', | |||
allow_multiple: false, | |||
restrictions: { | |||
allowed_file_types: ['image/*'] | |||
}, | |||
on_success: (file_doc) => { | |||
if (this.frm && !this.frm.is_new()) { | |||
this.frm.attachments.attachment_uploaded(file_doc); | |||
} | |||
this.editor.session.insert( | |||
this.editor.getCursorPosition(), | |||
`})` | |||
); | |||
} | |||
}); | |||
}); | |||
} | |||
}; |
@@ -319,6 +319,25 @@ frappe.ui.form.Form = class FrappeForm { | |||
}); | |||
} | |||
setup_image_autocompletions_in_markdown() { | |||
this.fields.map(field => { | |||
if (field.df.fieldtype === 'Markdown Editor') { | |||
this.set_df_property(field.df.fieldname, 'autocompletions', () => { | |||
let attachments = this.attachments.get_attachments(); | |||
return attachments | |||
.filter(file => frappe.utils.is_image_file(file.file_url)) | |||
.map(file => { | |||
return { | |||
caption: 'image: ' + file.file_name, | |||
value: ``, | |||
meta: 'image' | |||
}; | |||
}); | |||
}); | |||
} | |||
}); | |||
} | |||
// REFRESH | |||
refresh(docname) { | |||
@@ -533,6 +552,7 @@ frappe.ui.form.Form = class FrappeForm { | |||
// call onload post render for callbacks to be fired | |||
() => { | |||
if(this.cscript.is_onload) { | |||
this.onload_post_render(); | |||
return this.script_manager.trigger("onload_post_render"); | |||
} | |||
}, | |||
@@ -560,6 +580,10 @@ frappe.ui.form.Form = class FrappeForm { | |||
}); | |||
} | |||
onload_post_render() { | |||
this.setup_image_autocompletions_in_markdown(); | |||
} | |||
set_first_tab_as_active() { | |||
this.layout.tabs[0] | |||
&& this.layout.tabs[0].set_active(); | |||
@@ -62,7 +62,7 @@ frappe.ui.form.Attachments = class Attachments { | |||
} | |||
get_attachments() { | |||
return this.frm.get_docinfo().attachments; | |||
return this.frm.get_docinfo().attachments || []; | |||
} | |||
add_attachment(attachment) { | |||
var file_name = attachment.file_name; | |||