Merge pull request #632 from BookStackApp/draw.io

draw.io integration
This commit is contained in:
Dan Brown 2018-01-28 13:39:14 +00:00 committed by GitHub
commit 1d1cc19596
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 733 additions and 53 deletions

View File

@ -112,6 +112,7 @@ class ImageController extends Controller
* @param string $type
* @param Request $request
* @return \Illuminate\Http\JsonResponse
* @throws \Exception
*/
public function uploadByType($type, Request $request)
{
@ -120,10 +121,14 @@ class ImageController extends Controller
'file' => 'is_image'
]);
if (!$this->imageRepo->isValidType($type)) {
return $this->jsonError(trans('errors.image_upload_type_error'));
}
$imageUpload = $request->file('file');
try {
$uploadedTo = $request->filled('uploaded_to') ? $request->get('uploaded_to') : 0;
$uploadedTo = $request->get('uploaded_to', 0);
$image = $this->imageRepo->saveNew($imageUpload, $type, $uploadedTo);
} catch (ImageUploadException $e) {
return response($e->getMessage(), 500);
@ -132,6 +137,73 @@ class ImageController extends Controller
return response()->json($image);
}
/**
* Upload a drawing to the system.
* @param Request $request
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
*/
public function uploadDrawing(Request $request)
{
$this->validate($request, [
'image' => 'required|string',
'uploaded_to' => 'required|integer'
]);
$this->checkPermission('image-create-all');
$imageBase64Data = $request->get('image');
try {
$uploadedTo = $request->get('uploaded_to', 0);
$image = $this->imageRepo->saveDrawing($imageBase64Data, $uploadedTo);
} catch (ImageUploadException $e) {
return response($e->getMessage(), 500);
}
return response()->json($image);
}
/**
* Replace the data content of a drawing.
* @param string $id
* @param Request $request
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
*/
public function replaceDrawing(string $id, Request $request)
{
$this->validate($request, [
'image' => 'required|string'
]);
$this->checkPermission('image-create-all');
$imageBase64Data = $request->get('image');
$image = $this->imageRepo->getById($id);
$this->checkOwnablePermission('image-update', $image);
try {
$image = $this->imageRepo->replaceDrawingContent($image, $imageBase64Data);
} catch (ImageUploadException $e) {
return response($e->getMessage(), 500);
}
return response()->json($image);
}
/**
* Get the content of an image based64 encoded.
* @param $id
* @return \Illuminate\Http\JsonResponse|mixed
*/
public function getBase64Image($id)
{
$image = $this->imageRepo->getById($id);
$imageData = $this->imageRepo->getImageData($image);
if ($imageData === null) {
return $this->jsonError("Image data could not be found");
}
return response()->json([
'content' => base64_encode($imageData)
]);
}
/**
* Generate a sized thumbnail for an image.
* @param $id
@ -139,6 +211,8 @@ class ImageController extends Controller
* @param $height
* @param $crop
* @return \Illuminate\Http\JsonResponse
* @throws ImageUploadException
* @throws \Exception
*/
public function getThumbnail($id, $width, $height, $crop)
{
@ -153,6 +227,8 @@ class ImageController extends Controller
* @param integer $imageId
* @param Request $request
* @return \Illuminate\Http\JsonResponse
* @throws ImageUploadException
* @throws \Exception
*/
public function update($imageId, Request $request)
{

View File

@ -1,12 +1,9 @@
<?php namespace BookStack\Repos;
use BookStack\Image;
use BookStack\Page;
use BookStack\Services\ImageService;
use BookStack\Services\PermissionService;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Setting;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImageRepo
@ -132,6 +129,8 @@ class ImageRepo
* @param string $type
* @param int $uploadedTo
* @return Image
* @throws \BookStack\Exceptions\ImageUploadException
* @throws \Exception
*/
public function saveNew(UploadedFile $uploadFile, $type, $uploadedTo = 0)
{
@ -140,11 +139,39 @@ class ImageRepo
return $image;
}
/**
* Save a drawing the the database;
* @param string $base64Uri
* @param int $uploadedTo
* @return Image
* @throws \BookStack\Exceptions\ImageUploadException
*/
public function saveDrawing(string $base64Uri, int $uploadedTo)
{
$name = 'Drawing-' . user()->getShortName(40) . '-' . strval(time()) . '.png';
$image = $this->imageService->saveNewFromBase64Uri($base64Uri, $name, 'drawio', $uploadedTo);
return $image;
}
/**
* Replace the image content of a drawing.
* @param Image $image
* @param string $base64Uri
* @return Image
* @throws \BookStack\Exceptions\ImageUploadException
*/
public function replaceDrawingContent(Image $image, string $base64Uri)
{
return $this->imageService->replaceImageDataFromBase64Uri($image, $base64Uri);
}
/**
* Update the details of an image via an array of properties.
* @param Image $image
* @param array $updateDetails
* @return Image
* @throws \BookStack\Exceptions\ImageUploadException
* @throws \Exception
*/
public function updateImageDetails(Image $image, $updateDetails)
{
@ -170,6 +197,8 @@ class ImageRepo
/**
* Load thumbnails onto an image object.
* @param Image $image
* @throws \BookStack\Exceptions\ImageUploadException
* @throws \Exception
*/
private function loadThumbs(Image $image)
{
@ -188,6 +217,8 @@ class ImageRepo
* @param int $height
* @param bool $keepRatio
* @return string
* @throws \BookStack\Exceptions\ImageUploadException
* @throws \Exception
*/
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
{
@ -199,5 +230,29 @@ class ImageRepo
}
}
/**
* Get the raw image data from an Image.
* @param Image $image
* @return null|string
*/
public function getImageData(Image $image)
{
try {
return $this->imageService->getImageData($image);
} catch (\Exception $exception) {
return null;
}
}
/**
* Check if the provided image type is valid.
* @param $type
* @return bool
*/
public function isValidType($type)
{
$validTypes = ['drawing', 'gallery', 'cover', 'system', 'user'];
return in_array($type, $validTypes);
}
}

View File

@ -46,6 +46,50 @@ class ImageService extends UploadService
return $this->saveNew($imageName, $imageData, $type, $uploadedTo);
}
/**
* Save a new image from a uri-encoded base64 string of data.
* @param string $base64Uri
* @param string $name
* @param string $type
* @param int $uploadedTo
* @return Image
* @throws ImageUploadException
*/
public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, $uploadedTo = 0)
{
$splitData = explode(';base64,', $base64Uri);
if (count($splitData) < 2) {
throw new ImageUploadException("Invalid base64 image data provided");
}
$data = base64_decode($splitData[1]);
return $this->saveNew($name, $data, $type, $uploadedTo);
}
/**
* Replace the data for an image via a Base64 encoded string.
* @param Image $image
* @param string $base64Uri
* @return Image
* @throws ImageUploadException
*/
public function replaceImageDataFromBase64Uri(Image $image, string $base64Uri)
{
$splitData = explode(';base64,', $base64Uri);
if (count($splitData) < 2) {
throw new ImageUploadException("Invalid base64 image data provided");
}
$data = base64_decode($splitData[1]);
$storage = $this->getStorage();
try {
$storage->put($image->path, $data);
} catch (Exception $e) {
throw new ImageUploadException(trans('errors.path_not_writable', ['filePath' => $image->path]));
}
return $image;
}
/**
* Gets an image from url and saves it to the database.
* @param $url
@ -175,6 +219,19 @@ class ImageService extends UploadService
return $this->getPublicUrl($thumbFilePath);
}
/**
* Get the raw data content from an image.
* @param Image $image
* @return string
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
*/
public function getImageData(Image $image)
{
$imagePath = $this->getPath($image);
$storage = $this->getStorage();
return $storage->get($imagePath);
}
/**
* Destroys an Image object along with its files and thumbnails.
* @param Image $image

View File

@ -13,7 +13,12 @@ return [
| to have a conventional place to find your various credentials.
|
*/
// Single option to disable non-auth external services such as Gravatar and Draw.io
'disable_services' => env('DISABLE_EXTERNAL_SERVICES', false),
'drawio' => env('DRAWIO', !env('DISABLE_EXTERNAL_SERVICES', false)),
'callback_url' => env('APP_URL', false),
'mailgun' => [

View File

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="410.44821"
height="419.86591"
viewBox="0 0 108.59775 111.08952"
version="1.1"
id="svg8"
inkscape:version="0.92.2 (5c3e80d, 2017-08-06)"
sodipodi:docname="drawing.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.98994949"
inkscape:cx="314.26392"
inkscape:cy="340.27949"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="2560"
inkscape:window-height="1386"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-14.87623,-172.69189)">
<path
style="opacity:1;fill:#000016;fill-opacity:1;stroke:#000000;stroke-width:2.76340532;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 86.878856,250.68964 -11.880088,9.48754 11.880088,9.48722 z"
id="rect872"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<circle
style="opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path815"
cx="36.348648"
cy="196.87526"
r="18.972418" />
<rect
style="opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:4;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect817"
width="44.366741"
height="44.366741"
x="77.107246"
y="174.69189" />
<rect
style="opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:3.96875;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect836"
width="44.366741"
height="44.366741"
x="17.773417"
y="237.4303" />
<rect
style="opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:3.96875;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect838"
width="19.777945"
height="0.13363476"
x="56.260235"
y="196.77391" />
<rect
style="opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.0334897;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect838-6"
width="39.624786"
height="0.068895064"
x="220.46501"
y="-99.424637"
transform="rotate(90)" />
<rect
style="opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.02711964;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect838-6-7"
width="36.156651"
height="0.075265162"
x="-99.381981"
y="-260.21466"
transform="scale(-1)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -1,6 +1,8 @@
const MarkdownIt = require("markdown-it");
const mdTasksLists = require('markdown-it-task-lists');
const code = require('../code');
const code = require('../libs/code');
const DrawIO = require('../libs/drawio');
class MarkdownEditor {
@ -20,13 +22,26 @@ class MarkdownEditor {
init() {
let lastClick = 0;
// Prevent markdown display link click redirect
this.display.addEventListener('click', event => {
let link = event.target.closest('a');
if (link === null) return;
let isDblClick = Date.now() - lastClick < 300;
event.preventDefault();
window.open(link.getAttribute('href'));
let link = event.target.closest('a');
if (link !== null) {
event.preventDefault();
window.open(link.getAttribute('href'));
return;
}
let drawing = event.target.closest('[drawio-diagram]');
if (drawing !== null && isDblClick) {
this.actionEditDrawing(drawing);
return;
}
lastClick = Date.now();
});
// Button actions
@ -37,6 +52,7 @@ class MarkdownEditor {
let action = button.getAttribute('data-action');
if (action === 'insertImage') this.actionInsertImage();
if (action === 'insertLink') this.actionShowLinkSelector();
if (action === 'insertDrawing') this.actionStartDrawing();
});
window.$events.listen('editor-markdown-update', value => {
@ -290,6 +306,70 @@ class MarkdownEditor {
});
}
// Show draw.io if enabled and handle save.
actionStartDrawing() {
if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true') return;
let cursorPos = this.cm.getCursor('from');
DrawIO.show(() => {
return Promise.resolve('');
}, (pngData) => {
// let id = "image-" + Math.random().toString(16).slice(2);
// let loadingImage = window.baseUrl('/loading.gif');
let data = {
image: pngData,
uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
};
window.$http.post(window.baseUrl('/images/drawing/upload'), data).then(resp => {
let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
DrawIO.close();
}).catch(err => {
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
});
});
}
// Show draw.io if enabled and handle save.
actionEditDrawing(imgContainer) {
if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true') return;
let cursorPos = this.cm.getCursor('from');
let drawingId = imgContainer.getAttribute('drawio-diagram');
DrawIO.show(() => {
return window.$http.get(window.baseUrl(`/images/base64/${drawingId}`)).then(resp => {
return `data:image/png;base64,${resp.data.content}`;
});
}, (pngData) => {
let data = {
image: pngData,
uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
};
window.$http.put(window.baseUrl(`/images/drawing/upload/${drawingId}`), data).then(resp => {
let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url + `?updated=${Date.now()}`}"></div>`;
let newContent = this.cm.getValue().split('\n').map(line => {
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
return newText;
}
return line;
}).join('\n');
this.cm.setValue(newContent);
this.cm.setCursor(cursorPos);
this.cm.focus();
DrawIO.close();
}).catch(err => {
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
});
});
}
}
module.exports = MarkdownEditor ;

View File

@ -0,0 +1,69 @@
const drawIoUrl = 'https://www.draw.io/?embed=1&ui=atlas&spin=1&proto=json';
let iFrame = null;
let onInit, onSave;
/**
* Show the draw.io editor.
* @param onInitCallback - Must return a promise with the xml to load for the editor.
* @param onSaveCallback - Is called with the drawing data on save.
*/
function show(onInitCallback, onSaveCallback) {
onInit = onInitCallback;
onSave = onSaveCallback;
iFrame = document.createElement('iframe');
iFrame.setAttribute('frameborder', '0');
window.addEventListener('message', drawReceive);
iFrame.setAttribute('src', drawIoUrl);
iFrame.setAttribute('class', 'fullscreen');
iFrame.style.backgroundColor = '#FFFFFF';
document.body.appendChild(iFrame);
}
function close() {
drawEventClose();
}
function drawReceive(event) {
if (!event.data || event.data.length < 1) return;
let message = JSON.parse(event.data);
if (message.event === 'init') {
drawEventInit();
} else if (message.event === 'exit') {
drawEventClose();
} else if (message.event === 'save') {
drawEventSave(message);
} else if (message.event === 'export') {
drawEventExport(message);
}
}
function drawEventExport(message) {
if (onSave) {
onSave(message.data);
}
}
function drawEventSave(message) {
drawPostMessage({action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing'});
}
function drawEventInit() {
if (!onInit) return;
onInit().then(xml => {
drawPostMessage({action: 'load', autosave: 1, xml: xml});
});
}
function drawEventClose() {
window.removeEventListener('message', drawReceive);
if (iFrame) document.body.removeChild(iFrame);
}
function drawPostMessage(data) {
iFrame.contentWindow.postMessage(JSON.stringify(data), '*');
}
module.exports = {show, close};

View File

@ -1,5 +1,6 @@
"use strict";
const Code = require('../code');
const Code = require('../libs/code');
const DrawIO = require('../libs/drawio');
/**
* Handle pasting images from clipboard.
@ -47,7 +48,7 @@ function uploadImageFile(file) {
let formData = new FormData();
formData.append('file', file, remoteFilename);
return window.$http.post('/images/gallery/upload', formData).then(resp => (resp.data));
return window.$http.post(window.baseUrl('/images/gallery/upload'), formData).then(resp => (resp.data));
}
function registerEditorShortcuts(editor) {
@ -218,7 +219,103 @@ function codePlugin() {
});
}
codePlugin();
function drawIoPlugin() {
const drawIoUrl = 'https://www.draw.io/?embed=1&ui=atlas&spin=1&proto=json';
let iframe = null;
let pageEditor = null;
let currentNode = null;
function isDrawing(node) {
return node.hasAttribute('drawio-diagram');
}
function showDrawingEditor(mceEditor, selectedNode = null) {
pageEditor = mceEditor;
currentNode = selectedNode;
DrawIO.show(drawingInit, updateContent);
}
function updateContent(pngData) {
let id = "image-" + Math.random().toString(16).slice(2);
let loadingImage = window.baseUrl('/loading.gif');
let data = {
image: pngData,
uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
};
// Handle updating an existing image
if (currentNode) {
DrawIO.close();
let imgElem = currentNode.querySelector('img');
let drawingId = currentNode.getAttribute('drawio-diagram');
window.$http.put(window.baseUrl(`/images/drawing/upload/${drawingId}`), data).then(resp => {
pageEditor.dom.setAttrib(imgElem, 'src', `${resp.data.url}?updated=${Date.now()}`);
}).catch(err => {
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
});
return;
}
setTimeout(() => {
pageEditor.insertContent(`<div drawio-diagram contenteditable="false"><img src="${loadingImage}" id="${id}"></div>`);
DrawIO.close();
window.$http.post(window.baseUrl('/images/drawing/upload'), data).then(resp => {
pageEditor.dom.setAttrib(id, 'src', resp.data.url);
pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', resp.data.id);
}).catch(err => {
pageEditor.dom.remove(id);
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
});
}, 5);
}
function drawingInit() {
if (!currentNode) {
return Promise.resolve('');
}
let drawingId = currentNode.getAttribute('drawio-diagram');
return window.$http.get(window.baseUrl(`/images/base64/${drawingId}`)).then(resp => {
return `data:image/png;base64,${resp.data.content}`;
});
}
window.tinymce.PluginManager.add('drawio', function(editor, url) {
editor.addCommand('drawio', () => {
showDrawingEditor(editor);
});
editor.addButton('drawio', {
tooltip: 'Drawing',
image: window.baseUrl('/system_images/drawing.svg'),
cmd: 'drawio'
});
editor.on('dblclick', event => {
let selectedNode = editor.selection.getNode();
if (!isDrawing(selectedNode)) return;
showDrawingEditor(editor, selectedNode);
});
editor.on('SetContent', function () {
let drawings = editor.$('body > div[drawio-diagram]');
if (!drawings.length) return;
editor.undoManager.transact(function () {
drawings.each((index, elem) => {
elem.setAttribute('contenteditable', 'false');
});
});
});
});
}
window.tinymce.PluginManager.add('customhr', function (editor) {
editor.addCommand('InsertHorizontalRule', function () {
@ -242,7 +339,13 @@ window.tinymce.PluginManager.add('customhr', function (editor) {
});
});
// Load plugins
let plugins = "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor";
codePlugin();
if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') === 'true') {
drawIoPlugin();
plugins += ' drawio';
}
module.exports = {
selector: '#html-editor',
@ -259,12 +362,12 @@ module.exports = {
statusbar: false,
menubar: false,
paste_data_images: false,
extended_valid_elements: 'pre[*]',
extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram]',
automatic_uploads: false,
valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre]",
plugins: "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor",
valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre],+div[img]",
plugins: plugins,
imagetools_toolbar: 'imageoptions',
toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen",
toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr drawio | removeformat code fullscreen",
content_style: "body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}",
style_formats: [
{title: "Header Large", format: "h2"},

View File

@ -1,5 +1,5 @@
const Clipboard = require("clipboard");
const Code = require('../code');
const Code = require('../libs/code');
let setupPageShow = window.setupPageShow = function (pageId) {

View File

@ -1,4 +1,4 @@
const codeLib = require('../code');
const codeLib = require('../libs/code');
const methods = {
show() {

View File

@ -59,16 +59,21 @@
border: 1px solid #DDD;
width: 50%;
}
.markdown-display {
padding: 0 $-m 0;
margin-left: -1px;
overflow-y: scroll;
}
.markdown-display.page-content {
}
.markdown-display {
padding: 0 $-m 0;
margin-left: -1px;
overflow-y: scroll;
&.page-content {
margin: 0 auto;
max-width: 100%;
}
[drawio-diagram]:hover {
outline: 2px solid $primary;
}
}
.editor-toolbar {
width: 100%;
padding: $-xs $-m;

View File

@ -231,4 +231,16 @@ $btt-size: 40px;
input {
width: 100%;
}
}
.fullscreen {
border:0;
position:fixed;
top:0;
left:0;
right:0;
bottom:0;
width:100%;
height:100%;
z-index: 150;
}

View File

@ -162,6 +162,7 @@ return [
'pages_md_preview' => 'Preview',
'pages_md_insert_image' => 'Insert Image',
'pages_md_insert_link' => 'Insert Entity Link',
'pages_md_insert_drawing' => 'Insert Drawing',
'pages_not_in_chapter' => 'Page is not in a chapter',
'pages_move' => 'Move Page',
'pages_move_success' => 'Page moved to ":parentName"',

View File

@ -36,6 +36,7 @@ return [
'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.',
'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.',
'image_upload_error' => 'An error occurred uploading the image',
'image_upload_type_error' => 'The image type being uploaded is invalid',
// Attachments
'attachment_page_mismatch' => 'Page mismatch during attachment update',

View File

@ -1,5 +1,11 @@
<div class="page-editor flex-fill flex" id="page-editor" drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}" editor-type="{{ setting('app-editor') }}" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
<div class="page-editor flex-fill flex" id="page-editor"
drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}"
drawio-enabled="{{ config('services.drawio') ? 'true' : 'false' }}"
editor-type="{{ setting('app-editor') }}"
page-id="{{ $model->id or 0 }}"
page-new-draft="{{ $model->draft or 0 }}"
page-update-draft="{{ $model->isDraft or 0 }}">
{{ csrf_field() }}
@ -80,6 +86,10 @@
<div class="editor-toolbar">
<span class="float left">{{ trans('entities.pages_md_editor') }}</span>
<div class="float right buttons">
@if(config('services.drawio'))
<button class="text-button" type="button" data-action="insertDrawing"><i class="zmdi zmdi-widgets"></i>{{ trans('entities.pages_md_insert_drawing') }}</button>
&nbsp;|&nbsp
@endif
<button class="text-button" type="button" data-action="insertImage"><i class="zmdi zmdi-image"></i>{{ trans('entities.pages_md_insert_image') }}</button>
&nbsp;|&nbsp;
<button class="text-button" type="button" data-action="insertLink"><i class="zmdi zmdi-link"></i>{{ trans('entities.pages_md_insert_link') }}</button>

View File

@ -89,13 +89,16 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/user/all/{page}', 'ImageController@getAllForUserType');
// Standard get, update and deletion for all types
Route::get('/thumb/{id}/{width}/{height}/{crop}', 'ImageController@getThumbnail');
Route::get('/base64/{id}', 'ImageController@getBase64Image');
Route::put('/update/{imageId}', 'ImageController@update');
Route::post('/drawing/upload', 'ImageController@uploadDrawing');
Route::put('/drawing/upload/{id}', 'ImageController@replaceDrawing');
Route::post('/{type}/upload', 'ImageController@uploadByType');
Route::get('/{type}/all', 'ImageController@getAllByType');
Route::get('/{type}/all/{page}', 'ImageController@getAllByType');
Route::get('/{type}/search/{page}', 'ImageController@searchByType');
Route::get('/gallery/{filter}/{page}', 'ImageController@getGalleryFiltered');
Route::delete('/{imageId}', 'ImageController@destroy');
Route::delete('/{id}', 'ImageController@destroy');
});
// Attachments routes

View File

@ -13,17 +13,16 @@ abstract class BrowserKitTest extends TestCase
use DatabaseTransactions;
/**
* The base URL to use while testing the application.
*
* @var string
*/
protected $baseUrl = 'http://localhost';
// Local user instances
private $admin;
private $editor;
/**
* The base URL to use while testing the application.
* @var string
*/
protected $baseUrl = 'http://localhost';
public function tearDown()
{
\DB::disconnect();

View File

@ -1,7 +1,18 @@
<?php namespace Tests;
class ImageTest extends BrowserKitTest
use BookStack\Image;
use BookStack\Page;
class ImageTest extends TestCase
{
/**
* Get the path to our basic test image.
* @return string
*/
protected function getTestImageFilePath()
{
return base_path('tests/test-data/test-image.png');
}
/**
* Get a test image that can be uploaded
@ -10,7 +21,7 @@ class ImageTest extends BrowserKitTest
*/
protected function getTestImage($fileName)
{
return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-image.jpg'), $fileName, 'image/jpeg', 5238);
return new \Illuminate\Http\UploadedFile($this->getTestImageFilePath(), $fileName, 'image/jpeg', 5238);
}
/**
@ -28,13 +39,12 @@ class ImageTest extends BrowserKitTest
* Uploads an image with the given name.
* @param $name
* @param int $uploadedTo
* @return string
* @return \Illuminate\Foundation\Testing\TestResponse
*/
protected function uploadImage($name, $uploadedTo = 0)
{
$file = $this->getTestImage($name);
$this->call('POST', '/images/gallery/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
return $this->getTestImagePath('gallery', $name);
return $this->call('POST', '/images/gallery/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
}
/**
@ -43,25 +53,31 @@ class ImageTest extends BrowserKitTest
*/
protected function deleteImage($relPath)
{
unlink(public_path($relPath));
$path = public_path($relPath);
if (file_exists($path)) {
unlink($path);
}
}
public function test_image_upload()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$page = Page::first();
$admin = $this->getAdmin();
$imageName = 'first-image.jpg';
$this->actingAs($admin);
$relPath = $this->uploadImage($imageName, $page->id);
$this->assertResponseOk();
$imageName = 'first-image.png';
$relPath = $this->getTestImagePath('gallery', $imageName);
$this->deleteImage($relPath);
$upload = $this->uploadImage($imageName, $page->id);
$upload->assertStatus(200);
$this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image not found at path: '. public_path($relPath));
$this->deleteImage($relPath);
$this->seeInDatabase('images', [
$this->assertDatabaseHas('images', [
'url' => $this->baseUrl . $relPath,
'type' => 'gallery',
'uploaded_to' => $page->id,
@ -75,17 +91,18 @@ class ImageTest extends BrowserKitTest
public function test_image_delete()
{
$page = \BookStack\Page::first();
$page = Page::first();
$this->asAdmin();
$imageName = 'first-image.jpg';
$imageName = 'first-image.png';
$relPath = $this->uploadImage($imageName, $page->id);
$image = \BookStack\Image::first();
$this->uploadImage($imageName, $page->id);
$image = Image::first();
$relPath = $this->getTestImagePath('gallery', $imageName);
$this->call('DELETE', '/images/' . $image->id);
$this->assertResponseOk();
$delete = $this->delete( '/images/' . $image->id);
$delete->assertStatus(200);
$this->dontSeeInDatabase('images', [
$this->assertDatabaseMissing('images', [
'url' => $this->baseUrl . $relPath,
'type' => 'gallery'
]);
@ -93,4 +110,78 @@ class ImageTest extends BrowserKitTest
$this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has not been deleted as expected');
}
public function testBase64Get()
{
$page = Page::first();
$this->asAdmin();
$imageName = 'first-image.png';
$this->uploadImage($imageName, $page->id);
$image = Image::first();
$imageGet = $this->getJson("/images/base64/{$image->id}");
$imageGet->assertJson([
'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII='
]);
}
public function test_drawing_base64_upload()
{
$page = Page::first();
$editor = $this->getEditor();
$this->actingAs($editor);
$upload = $this->postJson('images/drawing/upload', [
'uploaded_to' => $page->id,
'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII='
]);
$upload->assertStatus(200);
$upload->assertJson([
'type' => 'drawio',
'uploaded_to' => $page->id,
'created_by' => $editor->id,
'updated_by' => $editor->id,
]);
$image = Image::where('type', '=', 'drawio')->first();
$this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: '. public_path($image->path));
$testImageData = file_get_contents($this->getTestImageFilePath());
$uploadedImageData = file_get_contents(public_path($image->path));
$this->assertTrue($testImageData === $uploadedImageData, "Uploaded image file data does not match our test image as expected");
}
public function test_drawing_replacing()
{
$page = Page::first();
$editor = $this->getEditor();
$this->actingAs($editor);
$this->postJson('images/drawing/upload', [
'uploaded_to' => $page->id,
'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDQ4S1RUeKwAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12NctNWSAQkwMaACUvkAfCkBmjyhGl4AAAAASUVORK5CYII='
]);
$image = Image::where('type', '=', 'drawio')->first();
$replace = $this->putJson("images/drawing/upload/{$image->id}", [
'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII='
]);
$replace->assertStatus(200);
$replace->assertJson([
'type' => 'drawio',
'uploaded_to' => $page->id,
'created_by' => $editor->id,
'updated_by' => $editor->id,
]);
$this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: '. public_path($image->path));
$testImageData = file_get_contents($this->getTestImageFilePath());
$uploadedImageData = file_get_contents(public_path($image->path));
$this->assertTrue($testImageData === $uploadedImageData, "Uploaded image file data does not match our test image as expected");
}
}

View File

@ -16,6 +16,12 @@ abstract class TestCase extends BaseTestCase
protected $admin;
protected $editor;
/**
* The base URL to use while testing the application.
* @var string
*/
protected $baseUrl = 'http://localhost';
/**
* Set the current user context to be an admin.
* @return $this

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B