]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #632 from BookStackApp/draw.io
authorDan Brown <redacted>
Sun, 28 Jan 2018 13:39:14 +0000 (13:39 +0000)
committerGitHub <redacted>
Sun, 28 Jan 2018 13:39:14 +0000 (13:39 +0000)
draw.io integration

22 files changed:
app/Http/Controllers/ImageController.php
app/Repos/ImageRepo.php
app/Services/ImageService.php
config/services.php
public/system_images/drawing.svg [new file with mode: 0644]
resources/assets/js/components/markdown-editor.js
resources/assets/js/libs/code.js [moved from resources/assets/js/code.js with 100% similarity]
resources/assets/js/libs/drawio.js [new file with mode: 0644]
resources/assets/js/pages/page-form.js
resources/assets/js/pages/page-show.js
resources/assets/js/vues/code-editor.js
resources/assets/sass/_forms.scss
resources/assets/sass/styles.scss
resources/lang/en/entities.php
resources/lang/en/errors.php
resources/views/pages/form.blade.php
routes/web.php
tests/BrowserKitTest.php
tests/ImageTest.php
tests/TestCase.php
tests/test-data/test-image.jpg [deleted file]
tests/test-data/test-image.png [new file with mode: 0644]

index d783507545d421ba63a536eaf5cbfeaaaf51dccd..e675bff0c5b52bbaef6e13db509f287ddfb31a63 100644 (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)
     {
index 5f04a74b19ab9dd938a3655ec94506aca56b099a..0c15a4310062446e47a37f869e6777ddf97790c2 100644 (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);
+    }
 
 }
\ No newline at end of file
index 43375ee094355dd214e7cb9e77af1d328438de8b..5eea285e5fda62ab74e6afb683277cf4a5d798e6 100644 (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
index ba9be69decb9a780a8052ed62d5fefaa78a9653c..8695ea91c461ddae30b43caaaa93089e5f6c3e4a 100644 (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'  => [
diff --git a/public/system_images/drawing.svg b/public/system_images/drawing.svg
new file mode 100644 (file)
index 0000000..9a9231a
--- /dev/null
@@ -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>
index 7b051dd12cee05957c6b08f087d16f22e535724e..3393829cc5c3712a5431976b37ac600ab8a4fbe7 100644 (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 isDblClick = Date.now() - lastClick < 300;
+
             let link = event.target.closest('a');
-            if (link === null) return;
+            if (link !== null) {
+                event.preventDefault();
+                window.open(link.getAttribute('href'));
+                return;
+            }
 
-            event.preventDefault();
-            window.open(link.getAttribute('href'));
+            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 ;
\ No newline at end of file
diff --git a/resources/assets/js/libs/drawio.js b/resources/assets/js/libs/drawio.js
new file mode 100644 (file)
index 0000000..beb6f0d
--- /dev/null
@@ -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};
\ No newline at end of file
index 904403fc1a5afbb2d9ceb2a0924d0eff5589aca4..f7bfe22cf86377eee9b63b29c45d7eb9adfd1a2c 100644 (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"},
index 6af5af57d8f979e0017f0482a41ccf141b3fe1a4..2efaf66c6ffa75a4799c196a56617763755bfc3d 100644 (file)
@@ -1,5 +1,5 @@
 const Clipboard = require("clipboard");
-const Code = require('../code');
+const Code = require('../libs/code');
 
 let setupPageShow = window.setupPageShow = function (pageId) {
 
index 35a98cc774c749d482103a79c0aedc72114e6909..c7926cf28b225479e9d1a5c60406e043fd3495cd 100644 (file)
@@ -1,4 +1,4 @@
-const codeLib = require('../code');
+const codeLib = require('../libs/code');
 
 const methods = {
     show() {
index 457d30e5488a81d060ec5d3235e678b5f792a35f..97620ff3fa20bcd8a8afac3dd16769945f0cf309 100644 (file)
     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;
index 6a80237c5b6a674b9cd4763408e6b82a95002f3c..2cb72bd75a0313280197c5fad42fbfe075f1db05 100644 (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;
 }
\ No newline at end of file
index 4dc5ccc382c1a242ca4a2f13153eb8f09a0d7191..6c5dd9f77c23de7b8613a3aa3ad054b8345ad62e 100644 (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"',
index 18ed63c6050b6dee07818af6622cb96271c4d0ed..bbcbdaec2dcf8b98fd74fe23db54fd2d0396256a 100644 (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',
index f450452cec8bce187f238ce89669efc48f0161af..53861527b5e8153b28a9be5dbdac1a2ff7a17f21 100644 (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() }}
 
                     <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>
index 06805714d7b644bf3beff65699c8b0473d6df315..a69e672e47ee7a1999b248397c64850045b0aacc 100644 (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
index d5c9911f8c619c3f41ff41a7d52cb2abc62f6e6c..a8ff0304422cfef5cf04a964e011cb3b3ff43de1 100644 (file)
@@ -13,17 +13,16 @@ abstract class BrowserKitTest extends TestCase
 
     use DatabaseTransactions;
 
+    // Local user instances
+    private $admin;
+    private $editor;
+
     /**
      * The base URL to use while testing the application.
-     *
      * @var string
      */
     protected $baseUrl = 'http://localhost';
 
-    // Local user instances
-    private $admin;
-    private $editor;
-
     public function tearDown()
     {
         \DB::disconnect();
index 3bb41138bae7d50dd8d9821fed27bf0ad54ebad0..c75617c0e1578e0e5f8cbd6e0837cd10251b45dd 100644 (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");
+    }
+
 }
\ No newline at end of file
index 81bd93ec4de41fdea805824cf22980a5ea785531..94751b0047843369a6a729b06fc7911456b1ead5 100644 (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
diff --git a/tests/test-data/test-image.jpg b/tests/test-data/test-image.jpg
deleted file mode 100644 (file)
index fb8da91..0000000
Binary files a/tests/test-data/test-image.jpg and /dev/null differ
diff --git a/tests/test-data/test-image.png b/tests/test-data/test-image.png
new file mode 100644 (file)
index 0000000..dd15f6e
Binary files /dev/null and b/tests/test-data/test-image.png differ