]> _ Git - fluidbook-toolbox.git/commitdiff
wip #7273 @1.5
authorVincent Vanwaelscappel <vincent@cubedesigners.com>
Thu, 23 Jan 2025 11:44:02 +0000 (12:44 +0100)
committerVincent Vanwaelscappel <vincent@cubedesigners.com>
Thu, 23 Jan 2025 11:44:02 +0000 (12:44 +0100)
34 files changed:
app/Http/Controllers/Admin/Operations/FluidbookPublication/LinksOperation.php
resources/linkeditor-stable/js/linkeditor.accessControl.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.clipboard.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.form.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.layers.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.links.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.links.lock.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.loader.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.panels.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.popup.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.resize.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.rulers.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.save.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.settings.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.toolbar.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.undo.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.utils.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.versions.js [new file with mode: 0644]
resources/linkeditor-stable/js/linkeditor.zoom.js [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_contextmenu.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_form.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_layers.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_links.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_mixins.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_panels.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_popup.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_rulers.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_toolbar.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_variables.sass [new file with mode: 0644]
resources/linkeditor-stable/style/inc/_versions.sass [new file with mode: 0644]
resources/linkeditor-stable/style/style.sass [new file with mode: 0644]
resources/linkeditor-stable/webpack.mix.js [new file with mode: 0644]
resources/views/vendor/backpack/crud/buttons/fluidbook_publication/edit.blade.php

index 6f53d877610afd9a8b73a254c86464701d8f8227..fa114b283bec123cb4867a48bcd5309ef8902ced 100644 (file)
@@ -4,23 +4,17 @@ namespace App\Http\Controllers\Admin\Operations\FluidbookPublication;
 
 // __('!!Paramètres des fluidbooks')
 
-use App\Fluidbook\Compiler\Compiler;
 use App\Fluidbook\Link\LinksData;
 use App\Models\FluidbookPublication;
 use App\Models\User;
 use Cubist\Backpack\Http\Controllers\Base\XSendFileController;
 use Cubist\Util\Files\Files;
-use Fluidbook\Tools\Links\Link;
-use Fluidbook\Tools\Links\TextLink;
+use Fluidbook\Tools\FluidbookFontToWoff;
 use Illuminate\Http\UploadedFile;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Route;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
-use Illuminate\Support\Facades\Broadcast;
 use Illuminate\Support\Str;
 use Illuminate\Http\Request;
-use Prologue\Alerts\Facades\Alert;
-use Fluidbook\Tools\FluidbookFontToWoff;
 
 
 trait LinksOperation
@@ -31,6 +25,7 @@ trait LinksOperation
 
     protected function setupLinksRoutes($segment, $routeName, $controller)
     {
+        Route::match(['get'], $segment . '/{id}/edit/links-beta', $controller . '@linksBeta')->name('fluidbook_linkeditor_beta');
         Route::match(['get'], $segment . '/{id}/edit/links', $controller . '@links')->name('fluidbook_linkeditor');
         Route::match(['post'], $segment . '/{id}/edit/links', $controller . '@broadcast')->name('fluidbook_linkeditor_post');
         Route::match(['get'], $segment . '/{id}/edit/links/versions', $controller . '@getLinkVersions');
@@ -85,27 +80,28 @@ trait LinksOperation
         return response()->json(['assets' => $fb->getLinksAssetsDimensions(), 'versions' => LinksData::getLinksVersions($fluidbook_id)]);
     }
 
-    protected function generateFont(Request $request, $id) {
+    protected function generateFont(Request $request, $id)
+    {
         $l = $request->links;
-        $textLinks = array_filter($l, function($n) {
+        $textLinks = array_filter($l, function ($n) {
             return $n['type'] === '35';
         });
         $fb = FluidbookPublication::find($id);
-        $wdir = $fb->protected_path('fluidbookpublication/working/'.$id).'/';
+        $wdir = $fb->protected_path('fluidbookpublication/working/' . $id) . '/';
         $css = [];
 
         foreach ($textLinks as $link) {
-            if(array_key_exists('image', $link)) {
+            if (array_key_exists('image', $link)) {
                 $fontFile = $link['image'];
                 $hash = 'fb_' . substr(md5($fontFile), 0, 10);
                 $final = $hash . '.woff';
                 $filepath = $wdir . '/' . $link['image'];
                 $dest = $wdir . '/' . $final;
-                if(!file_exists($dest)) {
+                if (!file_exists($dest)) {
                     FluidbookFontToWoff::fontforge($dest, $filepath);
                 }
 
-                $config = FluidbookFontToWoff::configFont($fontFile,$hash,$filepath);
+                $config = FluidbookFontToWoff::configFont($fontFile, $hash, $filepath);
                 $css[$hash]['capHeight'] = $config['capHeight'];
                 $css[$hash]['font'] = $hash;
                 $css[$hash]['hash'] = $final;
@@ -330,7 +326,18 @@ trait LinksOperation
 
         $token = Str::random(10);
 
-        return view('fluidbook_publication.link_editor', ['id' => $id, 'fluidbook' => FluidbookPublication::find($id), 'access' => "", 'token' => $token]);
+        return view('fluidbook_publication.link_editor', ['version' => 'stable', 'id' => $id, 'fluidbook' => FluidbookPublication::find($id), 'access' => "", 'token' => $token]);
+    }
+
+    public function linksBeta($id, Request $request)
+    {
+        if (!FluidbookPublication::hasPermission($id)) {
+            abort(401);
+        }
+
+        $token = Str::random(10);
+
+        return view('fluidbook_publication.link_editor', ['version' => 'beta', 'id' => $id, 'fluidbook' => FluidbookPublication::find($id), 'access' => "", 'token' => $token]);
     }
 
     public function broadcast(Request $request)
@@ -345,16 +352,16 @@ trait LinksOperation
         $queueEditor = cache()->get('queue_editor' . $fluidbookId); // on récupère la liste des connexions
 
         // on extrait seulement les connexions qui datent de pas moins de 5secondes
-        if(!empty($queueEditor)) {
-            if(sizeof($queueEditor) > 0) {
+        if (!empty($queueEditor)) {
+            if (sizeof($queueEditor) > 0) {
                 $queueEditor = array_filter($queueEditor, function ($n) {
                     return strtotime($n["date"]) > strtotime(now()) - 5;
                 });
             }
         }
 
-        if(!empty($queueEditor)) {
-            if(sizeof($queueEditor) > 0) {
+        if (!empty($queueEditor)) {
+            if (sizeof($queueEditor) > 0) {
                 // on supprime les connexions si la liste (tableau) est mal formatée
                 // en effet parfois la liste n'est pas un tableau multidimensionnel ce qui créé un bug
                 // un code à supprimer sur le long terme
@@ -412,12 +419,12 @@ trait LinksOperation
         if (cache()->has('queue_editor' . $fluidbookId) && !empty($queueEditor)) {
             $user = User::withoutGlobalScopes()->find(current($queueEditor)["userid"]);
             $user = $user->firstname . ' ' . $user->lastname;
-            if(sizeof($queueEditor) === 1) return json_encode(["status" => "available"]);
+            if (sizeof($queueEditor) === 1) return json_encode(["status" => "available"]);
 
             if (current($queueEditor)["token"] === $token) {
                 return json_encode(["status" => "available"]);
             } else {
-                return json_encode(["status"=>"unavailable","infos_connection"=>array_merge(["user" => $user,'id'=>current($queueEditor)["userid"]])]);
+                return json_encode(["status" => "unavailable", "infos_connection" => array_merge(["user" => $user, 'id' => current($queueEditor)["userid"]])]);
             }
         }
         return json_encode(["status" => "available"]);
diff --git a/resources/linkeditor-stable/js/linkeditor.accessControl.js b/resources/linkeditor-stable/js/linkeditor.accessControl.js
new file mode 100644 (file)
index 0000000..10b15c6
--- /dev/null
@@ -0,0 +1,82 @@
+function LinkeditorAccessControl(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.init();
+}
+
+LinkeditorAccessControl.prototype = {
+    init: function () {
+        this.interval = null
+        this.token = $("[data-token]").data("token")
+        this.userID = $("#userID").data('id')
+        const $this = this
+
+        $(document).on("click", "#connectTo", function(e) {
+            e.preventDefault()
+            clearTimeout($this.interval)
+            $this.intervalConnection("1");
+        })
+
+        $(window).on("unload",function(){
+            clearTimeout($this.interval)
+            sessionStorage.setItem('token', token);
+            localStorage.setItem('unload', '1');
+        })
+
+        this.interval = setTimeout(() => { $this.intervalConnection() }, 10 )
+    },
+
+    intervalConnection: function(clear = null) {
+        let unload = localStorage.getItem('unload')
+        let currentToken = sessionStorage.getItem('token')
+        const $this = this
+
+        $.ajax({
+            method: "POST",
+            url: '/fluidbook-publication/' + FLUIDBOOK_DATA.id + '/edit/links',
+            data: {id: FLUIDBOOK_DATA.id, token: currentToken ? currentToken : $this.token, unload: unload, clear: clear}
+        }).done(function (msg) {
+            let response = JSON.parse(msg)
+            if (response.status === "unavailable") {
+                if ($("#popup-overlay").find('.popup').length === 0) {
+                    $("#popup-overlay").addClass("show")
+
+                    let popupName = 'unavailable';
+                    if(response.infos_connection.id === $this.userID) popupName = 'unavailable-twin-connection';
+                    $this.linkeditor.popup.open(popupName);
+
+                    $this.linkeditor.save.saveIfUnsavedChanges("Sauvegarde après avoir été déconnecté par un autre utilisateur", false, function () {
+                    });
+
+                    // Bloquer les raccourcis clavier
+                    $this.linkeditor.controlKeyFilter(true)
+
+                    $("#username").text(response.infos_connection.user)
+                    $("#id").text(response.infos_connection.id)
+                }
+            } else {
+                if ($("#popup-overlay").find('.popup[data-popup^="unavailable"]').length > 0) {
+                    $("#popup-overlay").removeClass("show")
+                    window.linkeditor.popup.close();
+                }
+                $this.linkeditor.controlKeyFilter(false)
+                window.key.filter = function(event) {
+                    let tagName = (event.target || event.srcElement).tagName;
+                    //let field=tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'TEXTAREA';
+                    if (tagName === 'TEXTAREA' && event.keyCode === 13) {
+                        return false;
+                    }
+
+                    return true;
+                }
+            }
+            sessionStorage.removeItem('token');
+            localStorage.removeItem('unload');
+
+            $this.interval = setTimeout(() => {
+                $this.intervalConnection()
+            }, 2000)
+        });
+    },
+}
+
+export default LinkeditorAccessControl;
diff --git a/resources/linkeditor-stable/js/linkeditor.clipboard.js b/resources/linkeditor-stable/js/linkeditor.clipboard.js
new file mode 100644 (file)
index 0000000..5de9600
--- /dev/null
@@ -0,0 +1,112 @@
+function LinkeditorClipboard(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.content = '';
+    this.blocked = true;
+    this._isEmpty = true;
+
+    this.init();
+}
+
+LinkeditorClipboard.prototype = {
+    init: function () {
+        let $this = this;
+        if (!this.support()) {
+            return;
+        }
+        $(document).on('touchstart click', function () {
+            $this.checkBlocked();
+            return true;
+        });
+
+    },
+
+    checkBlocked: function () {
+        if (!this.blocked) {
+            return;
+        }
+        this.get(function (c) {
+        }, true);
+    },
+
+    enabled: function () {
+        return this.support();
+    },
+
+    support: function () {
+        return !this.linkeditor.utils.isFirefox();
+    },
+
+    empty: function () {
+        this.set('');
+    },
+
+    get: function (callback, force) {
+        let $this = this;
+        if (force || this.enabled()) {
+            navigator.clipboard.read()
+                .then(items => {
+                    $this.blocked = false;
+                    items[0].getType('text/html')
+                        .then(blob => {
+                            blob.text()
+                                .then(text => {
+                                    if (text.indexOf('<fluidbooklinks>') >= 0) {
+                                        $this.content = text;
+                                        callback($this.content);
+                                    }
+                                })
+                                .catch(err => {
+                                    callback(this.content);
+                                });
+                        })
+                        .catch(err => {
+                            callback(this.content);
+                        });
+                }).catch(err => {
+                callback(this.content);
+            });
+        } else {
+            callback(this.content);
+        }
+    },
+
+    isEmpty: function () {
+        if (!this.enabled()) {
+            return this.content === '';
+        }
+        return this._isEmpty;
+    },
+
+    checkEmpty: function () {
+        if (!this.enabled()) {
+            return;
+        }
+        let $this = this;
+        this.get(function (content) {
+            $this._isEmpty = content === '';
+        });
+    },
+
+    set: function (content) {
+        let $this = this;
+        this.content = content;
+
+        if (this.enabled()) {
+            const type = "text/html";
+            const blob = new Blob(['<fluidbooklinks>' + this.content + '</fluidbooklinks>'], {type});
+            const data = [new ClipboardItem({[type]: blob})];
+
+            navigator.clipboard.write(data).then(
+                () => {
+                    $this.checkEmpty();
+                    /* success */
+                },
+                () => {
+                    /* failure */
+                }
+            );
+        }
+    },
+}
+
+export default LinkeditorClipboard;
diff --git a/resources/linkeditor-stable/js/linkeditor.form.js b/resources/linkeditor-stable/js/linkeditor.form.js
new file mode 100644 (file)
index 0000000..88782a3
--- /dev/null
@@ -0,0 +1,373 @@
+function LinkeditorForm(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.loadingFields = {};
+    this._maskChangeEvent = false;
+    this._maskChangeEventTimeout = null;
+    this._saveFormDataAfterTextChangeTimeout=null;
+    this.init();
+}
+
+LinkeditorForm.prototype = {
+    init: function () {
+        var $this = this;
+
+        $(document).on('click', '.freefile-file a.upload', function (e) {
+            var c = $(this).closest('.freefile-file');
+            let f = $(c).find('.freefile-file-input');
+            if (e.originalEvent.ctrlKey && $(c).find('.freefile-file-input-directory').length > 0) {
+                f = $(c).find('.freefile-file-input-directory');
+            }
+            f.get(0).click();
+            return false;
+        });
+
+        $(document).on('change', '.calculation', function () {
+            var v = $(this).val();
+            if (/[0-9\., \-\+\*\/]+/.test(v)) {
+                try {
+                    v = eval(v.replace(/,/g, '.'));
+                    console.log(v);
+                    if (isNaN(v)) {
+                        return;
+                    }
+                } catch (e) {
+                    return;
+                }
+            }
+            $(this).val(v);
+        });
+
+        $(document).on('change', '#linkeditor-panel-form [name="type"]', function () {
+            console.log('change type');
+            if ($this._maskChangeEvent) {
+                return;
+            }
+            $this.saveFormDataInLink();
+            $this.updateLinkForm();
+        });
+
+        $(document).on('change keyup keydown', '#linkeditor-panel-form [name="uid"]', function () {
+            let v = $(this).val();
+            v = v.replace(/[^a-zA-Z0-9_]*/g, '');
+            if (v !== $(this).val()) {
+                $(this).val(v);
+            }
+            return true;
+        });
+
+        setInterval(function () {
+            if ($(document.activeElement).is('.sp-input')) {
+                var v = $(document.activeElement).val();
+                let main = $(document.activeElement).closest('.form-group').find('.colorpicker');
+                $(main).val(v);
+                $(main).trigger('change');
+
+            }
+        }, 250);
+
+        $(document).on('change keyup', "#linkeditor-panel-form input,#linkeditor-panel-form select,#linkeditor-panel-form textarea", function (e) {
+            if ($this._maskChangeEvent) {
+                return;
+            }
+
+            let callback = function () {
+                console.log('Save form data in link after change');
+                $this.saveFormDataInLink();
+            }
+
+            clearTimeout($this._saveFormDataAfterTextChangeTimeout);
+            if (e.type === 'change') {
+                callback();
+            } else {
+                $this._saveFormDataAfterTextChangeTimeout = setTimeout(callback, 500);
+            }
+        });
+
+
+        $(document).on('change', ".freefile-file-input, .freefile-file-input-directory", function () {
+            var form = $('#linkupload').clone();
+
+            var id = 'linkupload_' + Math.round(Math.random() * 10000000);
+            $(form).attr('id', id);
+            $(this).attr('form', id);
+            $('body').append(form);
+
+            var parent = $(this).closest('.freefile-file');
+            var tf = $(parent).find('.freefile-text-input');
+            let tfname = $(tf).attr('name');
+            let uid = $this.getCurrentFormUID();
+            $this.loadingFields[uid + '_' + tfname] = true;
+
+            $(parent).addClass('loading');
+            var f = $(form).ajaxSubmit({dataType: 'json'});
+            var xhr = f.data('jqxhr');
+            xhr.done(function (data) {
+                if ($this.getCurrentFormUID() === uid) {
+                    let tf = $("#linkeditor-panel-form").find('.freefile-text-input[name="' + tfname + '"]');
+                    tf.val(data[0]);
+                    tf.trigger('change');
+                    $(tf).closest('.freefile-file').removeClass('loading');
+                    $this.saveFormDataInLink();
+                } else {
+                    let linkData = {};
+                    linkData[tfname] = data[0];
+                    $this.linkeditor.links.updateLinkData(uid, linkData, true);
+                    $this.linkeditor.hasChanged();
+                }
+                $this.loadingFields[uid + '_' + tfname] = false;
+                setTimeout(function () {
+                    $this.linkeditor.save.saveIfUnsavedChanges(TRANSLATIONS.upload_save_message, false, function () {
+
+                    });
+                }, 500);
+
+            });
+        });
+    },
+
+    getCurrentFormUID: function () {
+        let uid = $("#linkeditor-panel-form").find('[name="uid"]');
+        if (uid.length > 0) {
+            return uid.val();
+        }
+        return false;
+    },
+
+    saveFormDataInLink: function () {
+        var $this = this;
+        var form = $("#linkeditor-panel-form form");
+        if ($(form).length === 0) {
+            return;
+        }
+
+        this.maskChangeEvent();
+        var link = $(form).data('link');
+        var data = this.serializeForm($(form));
+        var ignore = ['_method', '_referrer', '_token', 'http_referrer'];
+        var map = {y: 'top', x: 'left', w: 'width', h: 'height'};
+
+        var savedData = {};
+        var changed = false;
+        $.each(data, function (k, v) {
+            if (ignore.indexOf(k) >= 0) {
+                return;
+            }
+            let key = k;
+            if (map[k] !== undefined && map[k] !== null) {
+                key = map[k];
+            }
+            let currentValue = $this.normalizeFormValue(key, $(link).attr('fb-' + key));
+            v = $this.normalizeFormValue(key, v);
+
+            if (v !== currentValue) {
+                savedData[key] = v;
+                $(link).attr('fb-' + key, v);
+                changed = true;
+            }
+        });
+        if (changed) {
+            $(link).attr('fb-update', 1);
+            this.linkeditor.links.updateLinkData($(link).attr('fb-origuid'), savedData);
+            this.linkeditor.links.setLastSelectedLink(link);
+            this.linkeditor.hasChanged();
+        }
+        this.unmaskChangeEvent();
+    },
+
+
+    serializeForm: function (form) {
+        let data = $(form).serializeArray();
+        let res = {};
+        $.each(data, function (k, v) {
+            res[v.name] = v.value;
+        });
+        return res;
+    },
+
+    normalizeFormValue: function (k, value) {
+        if (value === undefined || value === null) {
+            value = '';
+        }
+        var number = ['top', 'left', 'width', 'height', 'rot', 'x', 'y', 'h', 'w'];
+        var bool = ['display_area'];
+        var integers = ['zindex'];
+        if (k === 'zindex' && (value === undefined || value === null || value === '')) {
+            value = -1;
+        }
+
+
+        if (integers.indexOf(k) >= 0) {
+            value = parseInt(value);
+            if (isNaN(value)) {
+                value = 0;
+            }
+        } else if (number.indexOf(k) >= 0) {
+            value = parseFloat(value);
+            if (isNaN(value)) {
+                value = 0;
+            }
+            value = this.linkeditor.utils.roundDimension(value);
+        } else if (bool.indexOf(k) >= 0) {
+            value = (value === true || value === 'true' || value === "1" || value === 1) ? '1' : '0';
+        }
+
+        value = value.toString()
+        value = value.replace(new RegExp("\r\n", 'g'), "\n");
+        value = value.replace(new RegExp("\r", 'g'), "\n");
+        value = value.replace(new RegExp("\n+", 'g'), "\n");
+        return value;
+    },
+
+    focusAndSelectDestinationField: function () {
+        var f = $('#linkeditor-panel-form [name="to"]');
+        if (f.length === 0) {
+            return;
+        }
+        if (!f.is('input,textarea')) {
+            return;
+        }
+        f = f.get(0);
+
+        f.focus();
+        f.select();
+        setTimeout(function () {
+            f.select();
+        }, 1);
+    },
+
+    emptyForm: function () {
+        $('#linkeditor-panel-form .select2_from_array').each(function () {
+            $(this).select2('close');
+            $(this).select2('destroy');
+        });
+        $('#linkeditor-panel-form').html('');
+    },
+
+    updateLinkForm: function () {
+        var links = $('.link.selected:not(.pendingCreate)');
+        if (links.length != 1) {
+            this.emptyForm();
+            return;
+        }
+
+        this.emptyForm();
+        var container = $('#linkeditor-panel-form');
+        var link = $(links).eq(0);
+        var type = $(link).attr('fb-type');
+        var form = $("#linkeditor-form-template-" + type).clone(false);
+
+        $(form).attr('id', null);
+        $(form).data('link', link);
+        $(form).find('.init-tooltip').removeClass('init-tooltip');
+        this.updateFormData(form);
+        container.append(form);
+        this.linkeditor.initTooltips();
+
+        this.initSelect2();
+        this.initSpectrum();
+
+        if ($(link).is('.new')) {
+            this.focusAndSelectDestinationField();
+        }
+    },
+
+    updateFormData: function (form) {
+        var $this = this;
+        try {
+            if (form === undefined) {
+                form = $('#linkeditor-panel-form form');
+                if (form.length === 0) {
+                    return;
+                }
+            }
+            var link = $(form).data('link');
+            if (link === undefined || link === null) {
+                return;
+            }
+            this.lastSelectedLink = link;
+        } catch (e) {
+            return;
+        }
+
+        let linkData = this.linkeditor.links.getLinkData(link);
+        let uid = linkData.uid;
+        var map = {top: 'y', left: 'x', width: 'w', height: 'h'};
+
+        this.maskChangeEvent();
+        $.each(linkData, function (k, v) {
+            if (map[k] !== undefined) {
+                k = map[k];
+            }
+            v = $this.normalizeFormValue(k, v);
+            if ($(form).find('[type="checkbox"][name=' + k + ']').length > 0) {
+                $(form).find('[type="checkbox"][name=' + k + ']').prop('checked', v === '1');
+            } else {
+                $(form).find('[name=' + k + ']').val(v);
+            }
+
+            if ($this.loadingFields[uid + '_' + k] === true) {
+                $(form).find('[name=' + k + ']').closest('.freefile-file').addClass('loading');
+            }
+        });
+        this.unmaskChangeEvent();
+    },
+
+
+    maskChangeEvent: function () {
+        this._maskChangeEvent = true;
+    },
+
+    unmaskChangeEvent: function () {
+        var $this = this;
+        clearTimeout(this._maskChangeEventTimeout);
+        if (this._maskChangeEvent === false) {
+            return;
+        }
+        this._maskChangeEventTimeout = setTimeout(function () {
+            $this._maskChangeEvent = false;
+        }, 100);
+    },
+
+
+    initSelect2: function () {
+        // trigger select2 for each untriggered select2 box
+        $('#linkeditor-panel-form .select2_from_array:not(.init)').each(function (i, obj) {
+            var options = {
+                theme: "bootstrap",
+            };
+            if ($(this).is('[data-allow-html="1"]')) {
+                options.escapeMarkup = function (m) {
+                    return m;
+                }
+            }
+            $(this).addClass('init')
+            $(this).select2(options);
+        });
+    },
+
+    initSpectrum: function () {
+        $("#linkeditor-panel-form .colorpicker:not(.init)").each(function () {
+            var t = $(this);
+            let s = $(this).spectrum({
+                preferredFormat: 'hex3',
+                showAlpha: true,
+                allowEmpty: $(t).attr('data-allow-empty') === 'true',
+                showInput: true,
+                showInitial: true,
+                showButtons: false,
+                flat: true,
+                move: function (color) {
+                    t.val(color.toString());
+                    $(t).trigger('change');
+                },
+                dragend: function (color) {
+                    t.val(color.toString());
+                    $(t).trigger('change');
+                }
+            });
+            $(this).addClass('init');
+        });
+    }
+
+};
+export default LinkeditorForm;
diff --git a/resources/linkeditor-stable/js/linkeditor.js b/resources/linkeditor-stable/js/linkeditor.js
new file mode 100644 (file)
index 0000000..7eece39
--- /dev/null
@@ -0,0 +1,612 @@
+import tippy from 'tippy.js';
+import 'tippy.js/dist/tippy.css';
+
+import Noty from "noty";
+import 'noty/lib/noty.css';
+import 'noty/lib/themes/mint.css';
+
+import GrahamScan from "@lucio/graham-scan";
+
+import LinkeditorLinks from './linkeditor.links';
+import LinkeditorLoader from './linkeditor.loader';
+import LinkeditorResize from './linkeditor.resize';
+import LinkeditorRulers from './linkeditor.rulers';
+import LinkeditorToolbar from './linkeditor.toolbar';
+import LinkeditorUtils from './linkeditor.utils';
+import LinkeditorZoom from './linkeditor.zoom';
+import LinkeditorSave from './linkeditor.save';
+import LinkeditorSettings from './linkeditor.settings';
+import LinkeditorPanels from './linkeditor.panels';
+import LinkeditorForm from './linkeditor.form';
+import LinkeditorVersions from './linkeditor.versions';
+import LinkeditorPopup from './linkeditor.popup';
+import LinkeditorLayers from "./linkeditor.layers";
+import LinkeditorUndo from './linkeditor.undo';
+import LinkeditorClipboard from './linkeditor.clipboard';
+import LinkeditorAccessControl from './linkeditor.accessControl';
+
+window.$ = window.jQuery = require('jquery');
+window.key = require('keymaster-reloaded');
+window.tippy = tippy;
+window.Noty = Noty;
+window.GrahamScan = GrahamScan;
+
+window.key.filter = function (event) {
+    return keyfilter(event);
+};
+
+function keyfilter(event, disable = false) {
+    if(disable) {
+        return false
+    }
+    let tagName = (event.target || event.srcElement).tagName;
+    //let field=tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'TEXTAREA';
+    if (tagName === 'TEXTAREA' && event.keyCode === 13) {
+        return false;
+    }
+
+    return true;
+}
+
+require('jquery.scrollto');
+require('jquery-contextmenu');
+import 'jquery-contextmenu/dist/jquery.contextMenu.css';
+
+require('jquery-form');
+require('spectrum-colorpicker');
+import 'spectrum-colorpicker/spectrum.css';
+
+require('select2');
+import 'select2/dist/css/select2.css';
+import 'select2-bootstrap-theme/dist/select2-bootstrap.css';
+import * as noty from "noty";
+
+window.MD5 = require("crypto-js/md5");
+
+$.ajaxSetup({
+    headers: {
+        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
+    }
+});
+
+function LinkEditor() {
+    this.mobileFirst = FLUIDBOOK_DATA.settings.mobileNavigationType === 'mobilefirst';
+    this.single = ['mobilefirst', 'portrait'].indexOf(FLUIDBOOK_DATA.settings.mobileNavigationType) >= 0 || FLUIDBOOK_DATA.settings.pages <= 1;
+
+    this.pw = FLUIDBOOK_DATA.settings.width;
+    this.ph = FLUIDBOOK_DATA.settings.height;
+    this.fw = this.pw * (this.single ? 1 : 2);
+    this.fh = this.ph;
+    this.fs = 1;
+    this.mx = 0;
+    this.my = 0;
+    this.bookSurface = this.pw * this.ph;
+
+    this.fluidbookRect = null;
+    this.canvasRect = null;
+    this.editorRect = null;
+    this.currentPage = -1;
+    this.currentNumericPage = 1;
+    this.maskHashEvent = false;
+    this.windowHasFocus = true;
+
+    this.rightClick = false;
+
+    this.dimensionProperties = ['left', 'top', 'width', 'height'];
+
+    this.init();
+}
+
+LinkEditor.prototype = {
+    init: function () {
+        var $this = this;
+
+        this.utils = new LinkeditorUtils(this);
+        this.clipboard = new LinkeditorClipboard(this);
+        this.toolbar = new LinkeditorToolbar(this);
+        this.resize = new LinkeditorResize(this);
+        this.rulers = new LinkeditorRulers(this);
+        this.zoom = new LinkeditorZoom(this);
+        this.links = new LinkeditorLinks(this);
+        this.loader = new LinkeditorLoader(this);
+        this.save = new LinkeditorSave(this);
+        this.panels = new LinkeditorPanels(this);
+        this.form = new LinkeditorForm(this);
+        this.settings = new LinkeditorSettings(this);
+        this.versions = new LinkeditorVersions(this);
+        this.layers = new LinkeditorLayers(this);
+        this.popup = new LinkeditorPopup(this);
+        this.undo = new LinkeditorUndo(this);
+        this.accessControl = new LinkeditorAccessControl(this);
+
+        this.initEvents();
+        this.initIcons();
+        this.panels.init();
+        this.zoom.reset();
+    },
+
+    initIcons: function () {
+        $("[data-icon]:not(.init-icon)").each(function () {
+            $(this).append(getSpriteIcon('linkeditor-' + $(this).data('icon'))).addClass('init-icon');
+        });
+    },
+
+    initTooltips: function () {
+        $('[data-tooltip]:not(.init-tooltip)').each(function () {
+            let i = tippy($(this).get(0), {content: $(this).data('tooltip')});
+            $(this).addClass('init-tooltip');
+            $(this).data('tippyinstance', i)
+        });
+    },
+
+    initEvents: function () {
+        var $this = this;
+
+        $(document).on('mousedown', '*', function (e) {
+            $this.rightClick = e.which !== 1;
+            return true;
+        });
+
+        $(document).on('mouseup', '*', function (e) {
+            if ($this.rightClick) {
+                setTimeout(function () {
+                    $this.rightClick = false;
+                }, 100);
+            }
+            $this.rightClick = false;
+            return true;
+        });
+
+        $(window).on('hashchange', function () {
+            if ($this.maskHashEvent) {
+                return;
+            }
+            $this.changePage();
+        });
+
+        $(window).on('blur', function () {
+            $this.windowHasFocus = false;
+        });
+
+        $(window).on('focus', function () {
+            if (!$this.windowHasFocus) {
+                $this.windowHasFocus = true;
+                $this.clipboard.checkEmpty();
+                $this.resetKeyModifiers();
+            }
+        });
+
+        key('esc', function () {
+            $this.resetKeyModifiers();
+            if ($this.popup.hasOpenPopup()) {
+                $this.popup.close();
+                return false;
+            }
+            return true;
+        });
+
+
+        $(window).on('keydown', function (e) {
+            if ($this.utils.isfocusOnFormItem()) {
+                return true;
+            }
+            if (e.keyCode == 16) {
+                $("#linkeditor-main").addClass('selection');
+            } else if (e.keyCode == 32) {
+                if (!$this.mobileFirst) {
+                    $("#linkeditor-main").addClass('grab');
+                }
+            } else if (e.keyCode == 18) {
+                $("#linkeditor-main").addClass('duplicate');
+
+            } else if (e.key == '>' || e.keyCode === 60 || e.keyCode === 226 || e.key==='<' || e.keyCode===188) {
+                $("#linkeditor-main").addClass('polygon');
+            }
+            $this.rulers.moveRuler();
+            return false;
+        });
+
+        $(window).on('keyup', function (e) {
+            if (e.keyCode == 16) {
+                $("#linkeditor-main").removeClass('selection');
+            } else if (e.keyCode == 32) {
+                $this.zoom.resetZoomDrag();
+            } else if (e.keyCode == 18) {
+                $("#linkeditor-main").removeClass('duplicate');
+            } else if (e.key == '>' || e.keyCode === 60 || e.keyCode === 226 || e.key==='<' || e.keyCode===188) {
+                $("#linkeditor-main").removeClass('polygon');
+                $this.links.closePolygonShape();
+            }
+            $this.rulers.moveRuler();
+            if ($this.utils.isfocusOnFormItem()) {
+                return true;
+            }
+            return false;
+        });
+
+        $(document).on('mousedown', "#linkeditor-editor", function (e) {
+            $this.setMouseCoordinates(e);
+            if ($this.rightClick) {
+                return true;
+            }
+            var deselectAll = true;
+
+            if ($('#linkeditor-main').hasClass('selection')) {
+                $this.links.startRectSelection();
+                return false;
+            } else if ($('#linkeditor-main').hasClass('duplicate')) {
+                $this.links.duplicateLinkClick();
+                return false;
+            } else if ($('#linkeditor-main').hasClass('grab')) {
+                $this.zoom.zoomdragging = {
+                    x: $this.mx,
+                    y: $this.my,
+                    scrollX: $("#linkeditor-canvas").scrollLeft(),
+                    scrollY: $("#linkeditor-canvas").scrollTop()
+                };
+                $(this).addClass('grabbing');
+            } else {
+                // Check if the user is not clicking the scrollbar
+                if ($this.mx < $this.canvasRect.right - 20 && $this.my < $this.canvasRect.bottom - 20) {
+                    $this.zoom.resetZoomDrag();
+                    $this.links.mouseDown();
+                    deselectAll = false;
+                }
+            }
+            if (deselectAll) {
+                $this.form.saveFormDataInLink();
+                $this.links.deselectAllLinks();
+            }
+        });
+
+        $(window).on('mousemove', function (e) {
+            $this.setMouseCoordinates(e);
+            if ($this.rightClick) {
+                return true;
+            }
+            $this.updateMousePosition(e);
+        });
+
+        $(window).on('mouseup', function (e) {
+            $this.setMouseCoordinates(e);
+            if ($this.rightClick) {
+                return true;
+            }
+            $this.panels.mouseup();
+            $this.zoom.mouseUp();
+            $this.rulers.mouseUp();
+            $this.links.mouseUp();
+        });
+
+        this.links.initEvents();
+
+        this.resize.resize();
+        this.changePage();
+    },
+
+    controlKeyFilter: function (disabled) {
+        window.key.filter = function (event) {
+            return keyfilter(event, disabled);
+        };
+    },
+
+    resetKeyModifiers: function () {
+        $("#linkeditor-main").removeClass('selection').removeClass('grab').removeClass('duplicate');
+    },
+
+    runAction: function (act, args) {
+        if (args === undefined) {
+            args = [];
+        }
+        if (typeof args === 'string') {
+            args = [args];
+        }
+        var a = act.split('.');
+        var o = this;
+        let po = this;
+        for (let i in a) {
+            po = o;
+            o = o[a[i]];
+        }
+        try {
+            return o.apply(po, args);
+        } catch (e) {
+            console.log(e);
+            console.error('Error while calling ' + act, args);
+        }
+    },
+
+    firstPage: function () {
+        this.changePage(1);
+    },
+
+    nextPage: function () {
+        this.changePage(this.currentNumericPage + (this.single ? 1 : 2));
+    },
+
+    previousPage: function () {
+        this.changePage(this.currentNumericPage - (this.single ? 1 : 2));
+    },
+
+    lastPage: function () {
+        this.changePage(FLUIDBOOK_DATA.settings.pages);
+    },
+
+    focusPageField: function () {
+        let i = $("#linkeditor-page-field input").get(0);
+        i.focus();
+        i.select();
+    },
+
+    hasChanged: function (push) {
+        if (push === undefined) {
+            push = true;
+        }
+        this.save.hasChanged();
+        if (push === true) {
+            this.undo.pushState();
+        }
+        this.layers.update();
+        this.updateFBElements(true);
+    },
+
+    updateFBElements: function (force) {
+        let $this = this;
+        requestAnimationFrame(function () {
+            $this._updateFBElements(force);
+        });
+    },
+
+    _updateFBElements: function (force) {
+        let $this = this;
+        let selector = '[fb-ref]';
+        if (force !== true) {
+            selector += '[fb-update="1"]';
+        }
+
+        $(selector).each(function () {
+            let e = $(this);
+            let rect = $(this).attr('fb-ref');
+            let css = {};
+            $.each($this.dimensionProperties, function (k, dim) {
+                if ($(e).is('[fb-' + dim + ']')) {
+                    let v = parseFloat($(e).attr('fb-' + dim));
+                    if (dim === 'width' || dim === 'height') {
+                        css[dim] = (v * ($this.fs * $this.zoom.zoom)) + 1;
+                    } else {
+                        css[dim] = $this.fluidbookTo(v, dim, rect);
+                    }
+                }
+            });
+            $(e).css(css).attr('fb-update', '0');
+        });
+        this.form.updateFormData();
+    },
+
+    getCurrentPageHeight: function () {
+        if (this.mobileFirst) {
+            return FLUIDBOOK_DATA.page_dimensions[this.currentPage][1];
+        }
+        return FLUIDBOOK_DATA.height;
+    },
+
+    fluidbookTo: function (dim, name, rect) {
+        switch (rect) {
+            case 'editor':
+                rect = this.editorRect;
+                break;
+            case 'canvas':
+                rect = this.canvasRect;
+                break;
+        }
+        return this.fluidbookToGlobal(dim, name) - rect[name];
+    },
+
+
+    updateMousePosition: function (e) {
+        if (e !== undefined) {
+            this.setMouseCoordinates(e);
+        }
+        if (this.rightClick) {
+            return;
+        }
+
+        this.panels.moveHandle();
+        this.rulers.updateMousePositionRulers();
+        this.rulers.moveRuler();
+        this.links.moveDragLink();
+        this.links.moveResizeLink();
+        this.links.movePolygonPoint();
+        this.zoom.updateMousePosition();
+    },
+
+    setMouseCoordinates: function (e) {
+        this.mx = e.pageX;
+        this.my = e.pageY;
+    },
+
+    changePage: function (page) {
+        // Save and deselect all links
+        this.links.deselectAllLinks();
+        this.form.emptyForm();
+
+        var $this = this;
+        this.maskHashEvent = true;
+        setTimeout(function () {
+            $this.maskHashEvent = false;
+        }, 500);
+        var formerPage = this.currentPage;
+        var wasSpecialPage = this.utils.isSpecialPage(formerPage);
+
+        if (page === undefined) {
+            let h = window.location.hash;
+            if (h.length === 0) {
+                page = 0;
+            } else {
+                page = window.location.hash.substring(1);
+            }
+        }
+
+        let normPage = this.utils.normalizePage(page);
+        let isSpecial = this.utils.isSpecialPage(normPage);
+
+        // Consider than clicking the special page act as a toggle
+        if (normPage === this.currentPage && isSpecial) {
+            normPage = this.currentNumericPage;
+            isSpecial = false;
+        }
+
+        // Keep the value of the numeric page to be able to use arrows to change page even from a special page
+        if (!isSpecial) {
+            this.currentNumericPage = normPage;
+        }
+
+        // Set icon of special page active
+        $("[data-special]").removeClass('active');
+        if (isSpecial) {
+            $("[data-special=" + normPage + "]").addClass('active');
+        }
+
+        if (normPage === this.currentPage) {
+            return;
+        }
+        this.currentPage = normPage;
+
+        window.location.hash = '#' + this.currentPage;
+        this.clearLinksAndRulers();
+        this.loader.loadPage(this.currentPage, 'left');
+        if (!this.single && !isSpecial) {
+            this.loader.loadPage(this.currentPage + 1, 'right');
+        }
+        $("#linkeditor-page-field input").val(this.currentNumericPage);
+        this.resize.resize();
+        if (this.mobileFirst || isSpecial || wasSpecialPage) {
+            this.zoom.reset();
+        }
+        this.undo.updateIconsStates();
+        this.loader.preloadPages();
+    },
+
+    getCurrentPages: function (page) {
+        if (page === undefined) {
+            page = this.currentPage;
+        } else {
+            page = this.utils.normalizePage(page);
+        }
+        if (this.single || this.utils.isSpecialPage(page)) {
+            return [page];
+        }
+        if (page % 2 === 1) {
+            page--;
+        }
+        return [page, page + 1];
+    },
+
+    toggleWhiteOverlay: function () {
+        $("#linkeditor-fluidbook").toggleClass('white-overlay');
+        if ($("#linkeditor-fluidbook").hasClass('white-overlay')) {
+            $('[data-action="toggleWhiteOverlay"]').addClass('active');
+        } else {
+            $('[data-action="toggleWhiteOverlay"]').removeClass('active');
+        }
+    },
+
+    togglePDFThumbnails: function () {
+        this.loader.togglePagesSource();
+        if (this.loader.pagesSource === 'thumbnails') {
+            $('[data-action="togglePDFThumbnails"]').addClass('active');
+        } else {
+            $('[data-action="togglePDFThumbnails"]').removeClass('active');
+        }
+    },
+
+    openFluidbook: function () {
+        window.open('/fluidbook-publication/preview/' + FLUIDBOOK_DATA.id + '_' + FLUIDBOOK_DATA.hash + '/#/page/' + this.currentPage);
+    },
+
+// Convert global coordinates to fluidbook ones
+    globalToFluidbook: function (x, y, onePage) {
+        let res = this._globalTo(x, y, this.fluidbookRect, 1 / (this.fs * this.zoom.zoom));
+        if (onePage) {
+            res.xside = 'left';
+            if (!this.single && res.x >= this.pw) {
+                res.xside = 'right';
+                res.x -= this.pw;
+            }
+        }
+        return res;
+    },
+
+    fluidbookToGlobal: function (dim, name) {
+        return this.fluidbookRect[name] + (dim * this.fs * this.zoom.zoom);
+    },
+
+    globalToCanvas: function (x, y) {
+        return this._globalTo(x, y, this.canvasRect, 1);
+    },
+
+    globalToEditor: function (x, y) {
+        return this._globalTo(x, y, this.editorRect, 1);
+    },
+
+    _globalTo: function (x, y, rect, multi) {
+        return {x: multi * (x - rect.x), y: multi * (y - rect.y)};
+    },
+
+    clearLinksAndRulers: function () {
+        this.rulers.clear();
+        this.links.clear();
+    },
+
+    notification: function (text, type, timeout) {
+        if (timeout === undefined) {
+            timeout = 5000;
+        }
+        if (type === undefined) {
+            type = 'success';
+        }
+        new Noty({
+            type: type,
+            text: text,
+            timeout: timeout,
+        }).show();
+    },
+
+    getCurrentPage: function () {
+        return this.currentPage;
+    },
+
+    displayLoader: function () {
+        $("#loader").show();
+    },
+
+    hideLoader() {
+        $("#loader").hide();
+    }
+
+}
+
+
+$(function () {
+    window.linkeditor = new LinkEditor();
+});
+
+(function (old) {
+    $.fn.attr = function () {
+        if (arguments.length === 0) {
+            if (this.length === 0) {
+                return null;
+            }
+
+            var obj = {};
+            $.each(this[0].attributes, function () {
+                if (this.specified) {
+                    obj[this.name] = this.value;
+                }
+            });
+            return obj;
+        }
+
+        return old.apply(this, arguments);
+    };
+})($.fn.attr);
diff --git a/resources/linkeditor-stable/js/linkeditor.layers.js b/resources/linkeditor-stable/js/linkeditor.layers.js
new file mode 100644 (file)
index 0000000..ddbca21
--- /dev/null
@@ -0,0 +1,128 @@
+function LinkeditorLayers(linkeditor) {
+    this.linkeditor = linkeditor;
+}
+
+LinkeditorLayers.prototype = {
+    init: function () {
+        var $this = this;
+
+        this.container = $("#linkeditor-panel-layers");
+        this.maskCheckEvents = false;
+
+        $(document).on('click', '#linkeditor-panel-layers a.lock', function () {
+            let uid = $(this).closest('.layer').find('input').attr('name');
+            $this.linkeditor.links.locks.toggleLock(uid);
+            return false;
+        });
+
+        $(document).on('click', "#linkeditor-panel-layers label", function (e) {
+            if ($this.maskCheckEvents) {
+                return false;
+            }
+            let uid = $(this).find('input').attr('name');
+            let checked = $(this).find('input').prop('checked');
+            var link = $('#linkeditor-links [fb-uid="' + uid + '"]');
+
+            if (!e.ctrlKey) {
+                $this.linkeditor.links.deselectAllLinks();
+                $this.linkeditor.links.selectLink(link);
+            } else {
+                if (checked) {
+                    $this.linkeditor.links.deselectLink(link);
+                } else {
+                    $this.linkeditor.links.selectLink(link);
+                }
+            }
+
+            $this.linkeditor.form.updateLinkForm();
+            return false;
+        });
+
+        $(document).on('click', '#linkeditor-panel-layers label span.uid', function () {
+            navigator.clipboard.writeText($(this).attr('fb-uid'));
+            let tippy = $(this).data('tippyinstance');
+            console.log(tippy);
+            tippy.setContent(TRANSLATIONS.id_copied);
+            tippy.show();
+            return false;
+        });
+
+        this.update();
+    },
+
+    update: function () {
+        if (this.container === undefined) {
+            return;
+        }
+        if (!this.container.hasClass('open')) {
+            return;
+        }
+        var $this = this;
+        this.container.html('');
+        var layers = [];
+        $('#linkeditor-links .link:not(.pendingCreate)').each(function () {
+            let type = $(this).attr('fb-type');
+            let dest = $(this).attr('fb-to');
+            if (dest === '') {
+                dest = '<em>' + TRANSLATIONS.empty + '</em>';
+            }
+            var l = '<div class="layer" data-locked="' + ($this.linkeditor.links.locks.isLocked($(this).attr('fb-uid')) ? '1' : '0') + '">';
+            l += '<label class="layer" fb-type="' + type + '">';
+            l += '<input name="' + $(this).attr('fb-uid') + '" type="checkbox"> ';
+            l += dest;
+            l += '<span data-tooltip="' + TRANSLATIONS.click_to_copy_id + '" data-uid="' + $(this).attr('fb-uid') + '" class="uid">#' + $(this).attr('fb-uid') + '</span>';
+            l += '</label>';
+            l += '<a href="#" class="lock" data-icon="lock" data-tooltip="' + TRANSLATIONS['lock'] + '"></a>';
+            l += '</div>';
+            let d = parseInt($(this).attr('fb-calc-depth'));
+            var m = 1;
+            if (d >= 30 && d < 50) {
+                m = 10;
+            }
+            let level = Math.floor((m * d) / 10) / m;
+            layers.push({
+                level: level,
+                zindex: parseInt($(this).attr('fb-calc-zindex')),
+                html: l
+            });
+        });
+        layers.sort(function (a, b) {
+            return b.zindex - a.zindex;
+        });
+        var seenLevels = {};
+        $.each(layers, function (k, v) {
+            if (seenLevels[v.level] === undefined) {
+                seenLevels[v.level] = true;
+                $this.container.append('<h3>' + TRANSLATIONS.level + ' #' + v.level + '</h3>');
+            }
+            $this.container.append(v.html);
+        });
+
+
+        this.updateSelection();
+        this.linkeditor.initTooltips();
+        this.linkeditor.initIcons();
+    },
+
+    updateSelection() {
+
+        if (this.container === undefined || this.container.is(':hidden')) {
+            return;
+        }
+        var $this = this;
+        this.maskCheckEvents = true;
+        setTimeout(function () {
+            $this.maskCheckEvents = false;
+        }, 100);
+
+        $('#linkeditor-links .link').each(function () {
+            let checkbox = $this.container.find('input[name=' + $(this).attr('fb-uid') + ']');
+            checkbox.prop('checked', $(this).is('.selected'));
+        });
+    },
+
+    resize: function () {
+
+    },
+};
+export default LinkeditorLayers;
diff --git a/resources/linkeditor-stable/js/linkeditor.links.js b/resources/linkeditor-stable/js/linkeditor.links.js
new file mode 100644 (file)
index 0000000..524023e
--- /dev/null
@@ -0,0 +1,1548 @@
+import LinkeditorLinksLock from './linkeditor.links.lock';
+
+var LinkeditorLinks = function (linkeditor) {
+    this.linkeditor = linkeditor;
+
+    this.dragLinkPos = null;
+    this.resizeLinkPos = null;
+    this.movePolygonPointPos = null;
+
+    this.magnetValuesX = [];
+    this.magnetValuesY = [];
+
+    this.lastSelectedLink = null;
+    this.lastSelectedLinkData = {'width': 100, 'height': 100, 'to': '', type: '2', target: '_blank'};
+
+    this.contextMenuPosition = null;
+
+    this.rectSelection = null;
+
+    this.dropTypes = [4, 6, 7, 12, 15, 16, 17, 18, 25, 30, 31];
+
+    this.locks = new LinkeditorLinksLock(linkeditor);
+
+    this.init();
+}
+
+LinkeditorLinks.prototype = {
+    init: function () {
+    },
+
+    initEvents: function () {
+        let $this = this;
+        $(document).on('mousedown', '.link .corners div', function (e) {
+            if ($this.linkeditor.rightClick) {
+                return true;
+            }
+            e.preventDefault();
+            e.stopPropagation();
+            $this.deselectAllLinks();
+            let link = $(this).closest('.link');
+            $this.selectLink(link);
+            if ($(this).hasClass('poly')) {
+                $this.startMovePolygonPoint($(this).data('point'));
+            } else {
+                $this.startResizeLink($(this).attr('class'));
+            }
+        });
+
+        $(document).on('mousedown', '.link', function (e) {
+            if ($this.linkeditor.rightClick) {
+                return true;
+            }
+            e.preventDefault();
+            e.stopPropagation();
+            var selectLink = false;
+            var deselectAll = false;
+            if (!$(this).hasClass('selected')) {
+                if (!e.ctrlKey) {
+                    deselectAll = true;
+                }
+                selectLink = true;
+            } else {
+                if (e.ctrlKey) {
+                    selectLink = false;
+                } else {
+                    selectLink = true;
+                }
+            }
+            if (deselectAll) {
+                $this.deselectAllLinks();
+            }
+            if (selectLink) {
+                $this.selectLink($(this));
+                $this.startDragLink();
+            } else {
+                $this.deselectLink($(this));
+            }
+
+            return false;
+        });
+
+
+        this.key('ctrl+alt+shift+f', function () {
+            $this.fixDriftedLinks();
+        });
+        this.key('ctrl+a', function () {
+            $this.selectAllExceptLocked();
+            return false;
+        });
+        this.key('ctrl+c', function () {
+            $this.copy();
+            return false;
+        });
+        this.key('ctrl+o', function () {
+            $this.locks.lockSelection();
+            return false;
+        });
+        this.key('ctrl+x', function () {
+            $this.cut();
+            return false;
+        });
+        this.key('ctrl+v', function () {
+            $this.paste();
+            return false;
+        });
+        this.key('del', function () {
+            $this.deleteSelection();
+        });
+        this.key('left', function () {
+            $this.offsetSelectedLinks('left', -1);
+        });
+        this.key('ctrl+left', function () {
+            $this.offsetSelectedLinks('left', -10);
+        });
+        this.key('right', function () {
+            $this.offsetSelectedLinks('left', 1);
+        });
+        this.key('ctrl+right', function () {
+            $this.offsetSelectedLinks('left', 10);
+        });
+        this.key('up', function () {
+            $this.offsetSelectedLinks('top', -1);
+        });
+        this.key('ctrl+up', function () {
+            $this.offsetSelectedLinks('top', -10);
+        });
+        this.key('down', function () {
+            $this.offsetSelectedLinks('top', 1);
+        });
+        this.key('ctrl+down', function () {
+            $this.offsetSelectedLinks('top', 10);
+        });
+        this.key('pageup', function () {
+            $this.selectPreviousLink();
+        });
+        this.key('pagedown', function () {
+            $this.selectNextLink();
+        });
+        this.key('ctrl+l', function () {
+            $this.openImageLink();
+            return false;
+        });
+        this.key('enter', function () {
+            if ($(document.activeElement).is('input[name="to"]')) {
+                $this.selectNextLink();
+                return false;
+            }
+        });
+
+        var commonDragAndDropEvent = function (e) {
+            e.originalEvent.dataTransfer.dropEffect = "copy";
+            e.preventDefault();
+        }
+        $(document).on('dragenter', '#linkeditor-main', function (e) {
+            // Prevent canvas being scrolled at the begining of the drag
+            $("#linkeditor-canvas").addClass('noscroll');
+            setTimeout(function () {
+                $("#linkeditor-canvas").removeClass('noscroll');
+            }, 2000);
+            commonDragAndDropEvent(e);
+        }).on('dragover', '#linkeditor-main', function (e) {
+            $('#linkeditor-main').addClass('dropfile');
+            commonDragAndDropEvent(e);
+        }).on('dragleave', '#linkeditor-main', function (e) {
+            $('#linkeditor-main').removeClass('dropfile');
+            commonDragAndDropEvent(e);
+        });
+
+        $(document).on('dragenter dragover', '.link.dropfile', function (e) {
+            commonDragAndDropEvent(e);
+            $(this).addClass('dragging');
+        });
+        $(document).on('dragleave', '.link.dropfile', function (e) {
+            commonDragAndDropEvent(e);
+            $(this).removeClass('dragging');
+        });
+        $(document).on('drop', '.link.dropfile', function (e) {
+            let link = this;
+            $this.deselectAllLinks();
+
+            commonDragAndDropEvent(e);
+            let uid = $(this).attr('fb-uid');
+            var fd = new FormData();
+            fd.append('entity', 'App\\Models\\FluidbookPublication');
+            fd.append('entry_id', FLUIDBOOK_DATA.id);
+            fd.append('attribute', 'linksAssets');
+            fd.append('file', e.originalEvent.dataTransfer.files[0]);
+            $.ajax({
+                url: '/filesorurlupload',
+                type: 'post',
+                data: fd,
+                contentType: false,
+                processData: false,
+                dataType: 'json',
+                success: function (response) {
+                    $(link).attr('fb-to', response[0]);
+                    $this.selectLink(link);
+                    LINKS[uid].to = response[0];
+                    $this.linkeditor.hasChanged(true);
+                }
+            });
+            $(this).removeClass('dragging');
+            $('#linkeditor-main').removeClass('dropfile');
+        });
+
+
+        $.contextMenu({
+            selector: '#linkeditor-canvas,.link,#linkeditor-panel-layers, #linkeditor-panel-layers label', events: {
+                show: function (e) {
+                    $this.contextMenuPosition = {x: $this.linkeditor.mx, y: $this.linkeditor.my};
+                }, preShow: function (e) {
+                    if ($(e).is('.link:not(.selected)')) {
+                        $this.deselectAllLinks();
+                        $this.selectLink(e);
+                    }
+                    if ($(e).is('#linkeditor-panel-layers label')) {
+                        let input = $(e).find('input');
+                        if (!input.prop('checked')) {
+                            $this.deselectAllLinks();
+                            $this.selectLink($('.link[fb-uid=' + input.attr('name') + ']'));
+                        }
+                    }
+                },
+            }, build: function ($triggerElement, e) {
+                var res = {
+                    callback: function () {
+
+                    }
+                };
+                var selection = $(".link.selected");
+                var multiple = selection.length > 1;
+                var hasSelection = selection.length > 0;
+                var hasClipboard = !$this.linkeditor.clipboard.isEmpty();
+
+                res.items = {
+                    'select_all': {
+                        isHtmlName: true, name: TRANSLATIONS.select_all + ' <kbd>Ctrl+A</kbd>', callback: function () {
+                            $this.selectAllExceptLocked();
+                        },
+                    }
+                };
+                if (hasSelection && !multiple) {
+                    res.items = $.extend(res.items, {
+                        'sep_link': '---------', 'copy_link_id': {
+                            isHtmlName: true, name: TRANSLATIONS.copy_link_id, callback: function () {
+                                navigator.clipboard.writeText(selection.attr('fb-uid'));
+                            },
+                        }
+                    });
+                    if (CAN_CONTAIN_LINKS[parseInt($(selection).attr('fb-type'))] !== undefined) {
+                        res.items.image_link = {
+                            isHtmlName: true,
+                            name: TRANSLATIONS.edit_image_link + ' <kbd>Ctrl+L</kbd>',
+                            callback: function () {
+                                $this.openImageLink();
+                            }
+                        };
+                    }
+                }
+                if (hasSelection) {
+                    res.items.sep_lock = '---------';
+                    res.items.lock = {
+                        isHtmlName: true, name: TRANSLATIONS.lock + ' <kbd>Ctrl+O</kbd>', callback: function () {
+                            $this.locks.lockSelection();
+                        },
+                    };
+                }
+                if (hasSelection || hasClipboard) {
+                    res.items.sep_clipboard = '---------';
+                    if (hasSelection) {
+                        res.items.copy_to_clipboad = {
+                            isHtmlName: true, name: TRANSLATIONS.copy + ' <kbd>Ctrl+C</kbd>', callback: function () {
+                                $this.copy();
+                            },
+                        };
+                        res.items.cut_to_clipboad = {
+                            isHtmlName: true, name: TRANSLATIONS.cut + ' <kbd>Ctrl+X</kbd>', callback: function () {
+                                $this.cut();
+                            },
+                        };
+                    }
+                    if (hasClipboard) {
+                        res.items.paste_here = {
+                            isHtmlName: true, name: TRANSLATIONS.paste_here, callback: function () {
+                                $this.paste($this.contextMenuPosition);
+                            },
+                        };
+                        res.items.paste_in_place = {
+                            isHtmlName: true,
+                            name: TRANSLATIONS.paste_in_place + ' <kbd>Ctrl+V</kbd>',
+                            callback: function () {
+                                $this.paste();
+                            },
+                        };
+                        res.items.paste_on_left = {
+                            isHtmlName: true,
+                            name: TRANSLATIONS.paste_on_left,
+                            callback: function () {
+                                $this.paste('left');
+                            },
+                        };
+                        res.items.paste_on_right = {
+                            isHtmlName: true,
+                            name: TRANSLATIONS.paste_on_right,
+                            callback: function () {
+                                $this.paste('right');
+                            },
+                        };
+                    }
+                }
+                if (hasSelection && !multiple) {
+                    res.items = $.extend(res.items, {
+                        'sep_extends': '---------', 'cover_1': {
+                            isHtmlName: true, name: TRANSLATIONS.cover_page_1, callback: function () {
+                                $this.coverPage(1, false);
+                            },
+                        }, 'cover_0': {
+                            isHtmlName: true, name: TRANSLATIONS.cover_page_0, callback: function () {
+                                $this.coverPage(0, false);
+                            },
+                        },
+                    });
+                    if (!$this.linkeditor.single) {
+                        res.items = $.extend(res.items, {
+                            'cover_double_1': {
+                                isHtmlName: true, name: TRANSLATIONS.cover_doublepage_1, callback: function () {
+                                    $this.coverPage(1, true);
+                                },
+                            }, 'cover_double_0': {
+                                isHtmlName: true, name: TRANSLATIONS.cover_doublepage_0, callback: function () {
+                                    $this.coverPage(0, true);
+                                },
+                            },
+                        });
+                    }
+                }
+                if (hasSelection) {
+                    res.items = $.extend(res.items, {
+                        'sep0': '---------', "delete": {
+                            isHtmlName: true,
+                            name: (multiple ? TRANSLATIONS.delete_selection : TRANSLATIONS.delete_link) + ' <kbd>Del</kbd>',
+                            callback: function () {
+                                $this.deleteSelection();
+                            }
+                        },
+                    });
+                }
+                return res;
+            },
+        });
+
+        setInterval(function () {
+            $this.checkLastSelectedLink();
+        }, 250);
+    },
+
+    key: function (shortcut, scope) {
+        var $this = this;
+        key(shortcut, function (event, handler) {
+            if ($this.allowsKeyboardShortcut(shortcut)) {
+                var res = scope(event, handler);
+                return res;
+            }
+        });
+    },
+
+    copy: function () {
+        this.copySelectionToClipboard(false);
+    },
+
+    cut: function () {
+        this.copySelectionToClipboard(true);
+    },
+
+    paste: function (frommouse) {
+        let $this = this;
+        this.linkeditor.clipboard.get(function (content) {
+            $this._paste(content, frommouse);
+        })
+    },
+
+    _paste: function (content, frommouse) {
+        var clipboard = $('<div />').html(content);
+
+        var linksInClipboard = clipboard.find(".link");
+        if (!linksInClipboard.length) {
+            return;
+        }
+        var $this = this;
+
+        let offset = {x: 0, y: 0};
+        if (frommouse === 'left') {
+            offset.x = -this.linkeditor.pw;
+        } else if (frommouse === 'right') {
+            offset.x = this.linkeditor.pw;
+        } else if (frommouse !== undefined) {
+            // Base mouse position should be the start of context menu (and not the mouse position when we click on the Paste in place item menu)
+            offset = this.linkeditor.globalToFluidbook(frommouse.x, frommouse.y, this.linkeditor.single);
+
+            var top = 1000000;
+            var left = 1000000;
+            // Get the coordinates of the top left corner of the links in clipboard
+            $(linksInClipboard).each(function () {
+                top = Math.min(parseFloat($(this).attr('fb-top')), top);
+                left = Math.min(parseFloat($(this).attr('fb-left')), left);
+            });
+            // The links will be pasted in order the links will be disposed as initially but starting from the mouse current position
+            offset.x -= left;
+            offset.y -= top;
+        }
+
+        this.deselectAllLinks();
+        $(linksInClipboard).each(function () {
+            let data = $this._duplicateLink($(this), false);
+
+            data.top += offset.y;
+            data.left += offset.x;
+
+            $this.selectLink($this.addLink(data, false));
+        });
+
+        this.linkeditor.hasChanged();
+        this.updatePolygonLinks();
+        this.updateLayers();
+    },
+
+    hasUIDLink: function (uid) {
+        return LINKS[uid] !== undefined && LINKS[uid] !== null;
+    },
+
+    copySelectionToClipboard: function (cut) {
+        var selection = this.getCurrentSelection();
+        if (selection.length === 0) {
+            return;
+        }
+        var $this = this;
+
+        let nb = 0;
+
+        var clipboardContent = $('<div />');
+        $.each(selection, function () {
+            let item = $(this);
+            let clone = $(item).clone();
+            if (cut) {
+                $this.deleteLink(item, false);
+            }
+            clipboardContent.append(clone);
+            nb++;
+        });
+
+        this.linkeditor.clipboard.set(clipboardContent.html());
+
+        if (cut) {
+            this.linkeditor.form.emptyForm();
+            this.linkeditor.hasChanged();
+            this.updateLayers();
+        }
+
+        var msg = cut ? TRANSLATIONS.n_links_cut : TRANSLATIONS.n_links_copied;
+        msg = msg.replace('%nb%', nb, msg)
+        this.linkeditor.notification(msg);
+    },
+
+
+    fixDriftedLinks: function () {
+        var $this = this;
+        var callback = function () {
+            $this._fixDriftedLinks();
+        }
+
+        //Restore links from 2022-12-07 13:37:15
+        this.linkeditor.save.saveIfUnsavedChanges(TRANSLATIONS.before_fix_drifted, false, callback);
+    },
+
+    _fixDriftedLinks: function () {
+        $.ajax({
+            url: '/fluidbook-publication/' + FLUIDBOOK_DATA.id + '/edit/links/fixdriftedlinks',
+            success: function (data) {
+                window.location.reload();
+            },
+        });
+    },
+
+    allowsKeyboardShortcut: function (shortcut) {
+        if (shortcut === 'pageup' || shortcut === 'pagedown' || shortcut === 'enter') {
+            return true;
+        }
+        return !this.linkeditor.utils.isfocusOnFormItem();
+    },
+
+    getLinkById: function (uid) {
+        return $('.link[fb-uid="' + uid + '"]');
+    },
+
+
+    openImageLink: function () {
+        let selection = $(".link.selected").eq(0);
+        if (undefined === CAN_CONTAIN_LINKS[parseInt($(selection).attr('fb-type'))] || selection.length !== 1) {
+            this.linkeditor.notification(TRANSLATIONS.error_open_image_link, 'warning');
+            return;
+        }
+        this.linkeditor.changePage('link_uid_' + selection.attr('fb-uid'));
+    },
+
+    selectLinkAndSelectToField: function (link) {
+        if ($(link).length === 0) {
+            return;
+        }
+        this.deselectAllLinks();
+        this.selectLink($(link));
+        this.updateSelection();
+        this.linkeditor.form.focusAndSelectDestinationField();
+    },
+
+    selectFirstLink: function () {
+        this.selectLinkAndSelectToField($('#linkeditor-links .link:eq(0)'));
+    },
+
+    selectLastLink: function () {
+        this.selectLinkAndSelectToField($('#linkeditor-links .link:last'));
+    },
+
+    selectPreviousLink: function () {
+        if (this.getCurrentSelection().length === 0) {
+            return this.selectLastLink();
+        }
+        return this.selectLinkAndSelectToField(this._getLinkByIndexOffset(-1));
+    },
+
+    selectNextLink: function () {
+        if (this.getCurrentSelection().length === 0) {
+            return this.selectFirstLink();
+        }
+        this.selectLinkAndSelectToField(this._getLinkByIndexOffset(1));
+    },
+
+    _getLinkByIndexOffset(way) {
+        let allLinks = this.getCurrentLinksOnPage();
+        let index = this.getFirstLinkInSelection().index();
+        let nb = allLinks.length;
+        let n = (nb + index + way) % nb;
+        return allLinks.eq(n);
+    },
+
+    cleanPendingCreateLink() {
+        $('.pendingCreate').remove();
+    },
+
+    createLinkDrag: function () {
+        var link = this.duplicateLinkDrag({width: 0, height: 0, polygon: null});
+        $(link).addClass('pendingCreate').addClass('new');
+        this.deselectAllLinks();
+        this.selectLink($(link));
+        this.startResizeLink('se');
+        this.updateLayers();
+    },
+
+    startResizeLink: function (corner) {
+        this.resizeLinkPos = {x: this.linkeditor.mx, y: this.linkeditor.my, corner: corner};
+        this.setDragOrigValues();
+    },
+
+    stopResizeLink: function () {
+        if (this.resizeLinkPos === null) {
+            return;
+        }
+        var $this = this;
+        this.moveResizeLink();
+        $('.pendingCreate').each(function () {
+            $this.deleteLink($(this), true);
+        });
+        this.linkeditor.form.updateLinkForm();
+        this.resizeLinkPos = null;
+        this.linkeditor.hasChanged();
+    },
+
+    moveResizeLink: function () {
+        if (this.resizeLinkPos === null) {
+            return;
+        }
+        let $this = this;
+        let f = 1 / (this.linkeditor.fs * this.linkeditor.zoom.zoom);
+        let dx = (this.linkeditor.mx - this.resizeLinkPos.x) * f;
+        let dy = (this.linkeditor.my - this.resizeLinkPos.y) * f;
+
+        let top = 0, left = 0, width = 0, height = 0;
+
+        if (['n', 'ne', 'nw'].indexOf(this.resizeLinkPos.corner) >= 0) {
+            top = dy;
+            height = -dy;
+        } else if (['s', 'se', 'sw'].indexOf(this.resizeLinkPos.corner) >= 0) {
+            height = dy;
+        }
+        if (['nw', 'w', 'sw'].indexOf(this.resizeLinkPos.corner) >= 0) {
+            left = dx;
+            width = -dx;
+        } else if (['ne', 'e', 'se'].indexOf(this.resizeLinkPos.corner) >= 0) {
+            width = dx;
+        }
+
+        let magnet = !key.ctrl;
+
+        $(".link.selected").each(function () {
+            let newWidth = $(this).data('drag-orig-width') + width;
+            let newHeight = $(this).data('drag-orig-height') + height;
+
+            if (key.shift) {
+                magnet = false;
+                // Keep ratio
+                let ratio = $(this).data('drag-orig-width') / $(this).data('drag-orig-height');
+                let xscale = newWidth / $(this).data('drag-orig-width');
+                let yscale = newHeight / $(this).data('drag-orig-height');
+                if (['ne', 'nw', 'se', 'sw'].indexOf($this.resizeLinkPos.corner) >= 0) {
+                    if (xscale < yscale) {
+                        newHeight = newWidth / ratio;
+                    } else {
+                        newWidth = newHeight * ratio;
+                    }
+                } else if (['e', 'w'].indexOf($this.resizeLinkPos.corner) >= 0) {
+                    newHeight = newWidth / ratio;
+                } else if (['n', 's'].indexOf($this.resizeLinkPos.corner) >= 0) {
+                    newWidth = newHeight * ratio;
+                }
+
+                width = newWidth - $(this).data('drag-orig-width');
+                height = newHeight - $(this).data('drag-orig-height');
+
+                if (['ne', 'e', 'se'].indexOf($this.resizeLinkPos.corner) >= 0) {
+                    left = 0;
+                } else if (['nw', 'w', 'sw'].indexOf($this.resizeLinkPos.corner) >= 0) {
+                    left = width;
+                }
+                if (['ne', 'n', 'nw'].indexOf($this.resizeLinkPos.corner) >= 0) {
+                    top = height;
+                } else if (['se', 's', 'sw'].indexOf($this.resizeLinkPos.corner) >= 0) {
+                    top = 0;
+                }
+            }
+
+            let newLeft = $(this).data('drag-orig-left') + left;
+            let newTop = $(this).data('drag-orig-top') + top;
+
+            if (magnet) {
+                let diff = 0;
+                if (left !== 0) {
+                    let magnetLeft = $this.linkeditor.utils.magnetize(newLeft, $this.magnetValuesX);
+                    diff = newLeft - magnetLeft;
+                    newLeft = magnetLeft;
+                    newWidth += diff;
+                }
+                if (top !== 0) {
+                    let magnetTop = $this.linkeditor.utils.magnetize(newTop, $this.magnetValuesY)
+                    diff = newTop - magnetTop;
+                    newTop = magnetTop;
+                    newHeight += diff;
+                }
+                if (width !== 0) {
+                    let magnetLeft = $this.linkeditor.utils.magnetize(newLeft, $this.magnetValuesX, newWidth, true);
+                    diff = newLeft - magnetLeft;
+                    newWidth -= diff;
+                }
+                if (height !== 0) {
+                    let magnetTop = $this.linkeditor.utils.magnetize(newTop, $this.magnetValuesY, newHeight, true);
+                    diff = newTop - magnetTop;
+                    newHeight -= diff;
+                }
+            }
+
+            if (newWidth < 0) {
+                newWidth *= -1;
+                newLeft -= newWidth;
+            }
+            if (newHeight < 0) {
+                newHeight *= -1;
+                newTop -= newHeight;
+            }
+
+            $(this).attr('fb-left', newLeft)
+                .attr('fb-top', newTop)
+                .attr('fb-width', newWidth)
+                .attr('fb-height', newHeight)
+                .attr('fb-update', '1');
+
+            if ($(this).hasClass('pendingCreate') && newWidth > 2 && newHeight > 2) {
+                $(this).removeClass('pendingCreate');
+            }
+
+            $this.updateLinkData($(this).attr('fb-origuid'), {
+                top: newTop, left: newLeft, width: newWidth, height: newHeight
+            });
+        });
+        this.linkeditor.updateFBElements(false);
+        this.linkeditor.save.hasChanged();
+    },
+
+
+    startDragLink: function () {
+        this.dragLinkPos = {x: this.linkeditor.mx, y: this.linkeditor.my};
+        this.setDragOrigValues();
+    },
+
+    setDragOrigValues: function () {
+        $(".link.selected").each(function () {
+            $(this).data('drag-orig-left', parseFloat($(this).attr('fb-left')));
+            $(this).data('drag-orig-top', parseFloat($(this).attr('fb-top')));
+            $(this).data('drag-orig-width', parseFloat($(this).attr('fb-width')));
+            $(this).data('drag-orig-height', parseFloat($(this).attr('fb-height')));
+        });
+    },
+
+    stopDragLink: function () {
+        if (this.dragLinkPos === null) {
+            return;
+        }
+        this.moveDragLink();
+        this.dragLinkPos = null;
+        this.linkeditor.hasChanged();
+    },
+
+    moveDragLink: function () {
+        if (this.updateRectSelection()) {
+            return;
+        }
+        if (this.dragLinkPos === null) {
+            return;
+        }
+
+        let magnet = !key.ctrl;
+        let $this = this;
+        let f = 1 / (this.linkeditor.fs * this.linkeditor.zoom.zoom);
+        let dx = (this.linkeditor.mx - this.dragLinkPos.x) * f;
+        let dy = (this.linkeditor.my - this.dragLinkPos.y) * f;
+
+        var rect = {left: 10000000, top: 10000000, right: 0, bottom: 0};
+
+        if (magnet) {
+            $(".link.selected").each(function () {
+                let left = $(this).data('drag-orig-left') + dx;
+                let top = $(this).data('drag-orig-top') + dy;
+                let right = left + $(this).data('drag-orig-width');
+                let bottom = top + $(this).data('drag-orig-height');
+
+                rect.left = Math.min(left, rect.left);
+                rect.right = Math.max(right, rect.right);
+                rect.top = Math.min(top, rect.top);
+                rect.bottom = Math.max(bottom, rect.bottom);
+            });
+
+            let rx = $this.linkeditor.utils.magnetize(rect.left, $this.magnetValuesX, rect.right - rect.left)
+            let ry = $this.linkeditor.utils.magnetize(rect.top, $this.magnetValuesY, rect.bottom - rect.top);
+
+            dx -= rect.left - rx;
+            dy -= rect.top - ry;
+        }
+
+
+        $(".link.selected").each(function () {
+            let left = $(this).data('drag-orig-left') + dx;
+            let top = $(this).data('drag-orig-top') + dy;
+
+            $(this)
+                .attr('fb-left', left)
+                .attr('fb-top', top)
+                .attr('fb-update', '1');
+
+            $this.updateLinkData($(this).attr('fb-origuid'), {
+                top: top, left: left,
+            });
+        });
+        this.linkeditor.updateFBElements(false);
+        this.linkeditor.save.hasChanged();
+    },
+
+    selectLink: function (l) {
+        if ($(l).find('.corners').length === 0) {
+            $(l).append('<div class="corners"><div class="nw"></div><div class="n"></div><div class="ne"></div><div class="e"></div><div class="se"></div><div class="s"></div><div class="sw"></div><div class="w"></div></div>')
+        }
+        $(l).addClass('selected');
+
+        this.linkeditor.form.updateLinkForm();
+        if ($(l).is(':visible') && !$(l).is('.pendingCreate') && parseFloat($(l).attr('fb-width')) > 2 && parseFloat($(l).attr('fb-height')) > 2) {
+            this.setLastSelectedLink(l);
+        }
+        this.updateSelection();
+        if ($(l).hasClass('new')) {
+            this.linkeditor.form.focusAndSelectDestinationField();
+        }
+    },
+
+    setLastSelectedLink: function (l) {
+        this.lastSelectedLink = l;
+        this.checkLastSelectedLink();
+    },
+
+    checkLastSelectedLink: function () {
+        if (this.lastSelectedLink === null || $(this.lastSelectedLink).length === 0) {
+            return;
+        }
+        this.lastSelectedLinkData = this.getLinkData(this.lastSelectedLink);
+    },
+
+    roundPos: function (v) {
+
+    },
+
+    getLinkData: function (l) {
+        var res = {};
+        var attributes = $(l).attr();
+        var skip = ['fb-ref', 'fb-update'];
+        for (var key in attributes) {
+            if (key.indexOf('fb-') !== 0 || skip.indexOf(key) >= 0) {
+                continue;
+            }
+            res[key.substring(3)] = attributes[key];
+        }
+        return res;
+    },
+
+    deselectAllLinks: function () {
+        this.linkeditor.form.saveFormDataInLink();
+        this.linkeditor.form.updateLinkForm();
+        $(".link.selected").removeClass('selected').removeClass('new');
+        this.updateSelection();
+    },
+
+    deselectLink: function (link) {
+        $(link).removeClass('selected').removeClass('new');
+        this.updateSelection();
+    },
+
+    offsetSelectedLinks: function (dim, value) {
+        var $this = this;
+        $('.link.selected').each(function () {
+            let v = parseFloat($(this).attr('fb-' + dim));
+            let newValue = v + value;
+            let data = {};
+            data[dim] = newValue;
+            $(this).attr('fb-' + dim, newValue).attr('fb-update', '1');
+            $this.updateLinkData($(this).attr('fb-origuid'), data);
+        });
+        this.linkeditor.updateFBElements();
+        this.linkeditor.form.updateFormData();
+    },
+
+    getLinksOfPage: function (p) {
+        let pages = [];
+        if (!this.linkeditor.single) {
+            if (p % 2 === 1) {
+                p--;
+            }
+            pages.push(p);
+            pages.push(p + 1);
+        } else {
+            pages.push(p);
+        }
+        let res = {};
+        $.each(LINKS, function (uid, link) {
+            if (pages.indexOf(parseInt(link.page)) >= 0) {
+                res[uid] = link;
+            }
+        });
+        return res;
+    },
+
+    loadLinks: function (page, side) {
+        let $this = this;
+        this.normalizeLinksPage();
+        $.each(LINKS, function (uid, link) {
+            if ($('#linkeditor-links [fb-uid="' + uid + '"]').length > 0) {
+                return;
+            }
+            if (link.page != page) {
+                return;
+            }
+            $this.addLink(link, false);
+        });
+        this.updatePolygonLinks();
+        this.updateLayers();
+        this.locks.update();
+        this.linkeditor.undo.initState();
+    },
+
+    normalizeLinksPage() {
+        let pw = this.linkeditor.pw;
+        let single = this.linkeditor.single;
+        $.each(LINKS, function (uid, link) {
+            link.left = parseFloat(link.left)
+            if (!single) {
+                if (link.page % 2 === 0 && link.left > pw) {
+                    link.page++;
+                    link.left -= pw;
+                }
+                if (link.page % 2 === 1) {
+                    link.page--;
+                    link.left += pw;
+                }
+            }
+            LINKS[uid] = link;
+        });
+    },
+
+
+    addLink: function (link, triggerChange) {
+        if (triggerChange === undefined) {
+            triggerChange = true;
+        }
+        let change = false;
+        if (link.uid === undefined) {
+            link.uid = this.linkeditor.utils.generateUID();
+            change = true;
+        } else if (!this.hasUIDLink(link.uid)) {
+            change = true;
+        }
+        if (change) {
+            LINKS[link.uid] = link;
+        }
+
+        link.origuid = link.uid;
+
+        let $this = this;
+
+        let attrs = {};
+        $.each(link, function (k, v) {
+            attrs['fb-' + k] = $this.linkeditor.form.normalizeFormValue(k, v);
+        });
+        attrs['fb-ref'] = "editor";
+        attrs['fb-update'] = "1";
+
+
+        let e = $('<div class="link" fb-ref="editor" fb-update="1"></div>');
+        $(e).attr(attrs);
+        if (this.dropTypes.indexOf(parseInt(link.type)) >= 0) {
+            $(e).addClass('dropfile');
+        }
+        $("#linkeditor-links").append(e);
+        if (triggerChange && change) {
+            this.linkeditor.rulers.updateMagnetValues();
+            this.linkeditor.hasChanged();
+        }
+        return e;
+    },
+
+    updateMagnetValues: function () {
+        let $this = this;
+        this.magnetValuesX = [];
+        this.magnetValuesY = [];
+        $.each(this.linkeditor.rulers.getRulersOfPage(), function (uid, ruler) {
+            if (ruler.type === 'y') {
+                $this.magnetValuesY.push(ruler.pos);
+            } else {
+                $this.magnetValuesX.push(ruler.pos);
+            }
+        });
+    },
+
+    _duplicateLink: function (link, pos) {
+        var data;
+        if (link === undefined) {
+            data = this.lastSelectedLinkData;
+        } else {
+            data = this.getLinkData(link);
+        }
+        data.page = this.linkeditor.currentPage;
+        if (pos === undefined) {
+            pos = this.linkeditor.globalToFluidbook(this.linkeditor.mx, this.linkeditor.my, this.linkeditor.single);
+        }
+        if (pos !== false) {
+            data.left = pos.x;
+            data.top = pos.y;
+        } else {
+            data.left = parseFloat(data.left);
+            data.top = parseFloat(data.top);
+        }
+        if (this.hasUIDLink(data.uid)) {
+            delete data.uid;
+        }
+        return data;
+    },
+
+    duplicateLinkClick: function () {
+        let link = this.addLink(this._duplicateLink(), true);
+        $(link).addClass('new');
+
+        this.deselectAllLinks();
+        this.selectLink($(link));
+        this.updatePolygonLinks();
+        this.linkeditor.form.updateFormData();
+
+        return $(link);
+    },
+
+    duplicateLinkDrag(overwriteData) {
+        var data = this._duplicateLink();
+        if (overwriteData !== undefined) {
+            $.extend(data, overwriteData);
+        }
+        return $(this.addLink(data, false));
+    },
+
+    updateLayers: function () {
+        this.updateDepths();
+        this.linkeditor.layers.update();
+    },
+
+    deleteSelection: function () {
+        var $this = this;
+        this.linkeditor.form.emptyForm();
+        $(".link.selected").each(function () {
+            $this.deleteLink(this, false);
+        });
+        this.linkeditor.hasChanged();
+    },
+
+    getCurrentSelection: function () {
+        return $('#linkeditor .link.selected:not(.pendingCreate)');
+    },
+
+    getFirstLinkInSelection: function () {
+        return this.getCurrentSelection().eq(0);
+    },
+
+    getCurrentLinksOnPage() {
+        return $('.link:not(.pendingCreate)');
+    },
+
+    deleteLink: function (link, triggerChange) {
+        if (triggerChange === undefined) {
+            triggerChange = true;
+        }
+        delete LINKS[$(link).attr('fb-origuid')];
+        $(link).remove();
+        if (triggerChange === true) {
+            this.linkeditor.hasChanged();
+        }
+        this.updateLayers();
+        this.updateSelection();
+    },
+
+    selectAll: function () {
+        let $this = this;
+        $('.link').each(function () {
+            $this.selectLink($(this));
+        });
+        this.updateSelection();
+    },
+
+    selectAllExceptLocked: function () {
+        let $this = this;
+        $('.link:not([data-locked="1"])').each(function () {
+            $this.selectLink($(this));
+        });
+        this.updateSelection();
+    },
+
+    updateSelection: function () {
+        $("#linkeditor").attr('data-selection-count', this.getCurrentSelection().length);
+        this.linkeditor.layers.updateSelection();
+    },
+
+    updateDepths: function () {
+        var $this = this;
+        $("#linkeditor-links .link").each(function () {
+            let calcDepth = parseInt($(this).attr('fb-zindex'));
+            if (isNaN(calcDepth) || calcDepth === -1) {
+                calcDepth = $this.findDefaultLinkDepth($(this));
+            }
+            let linkWidth = parseFloat($(this).attr('fb-width'));
+            let linkHeight = parseFloat($(this).attr('fb-height'));
+            let zindex = ((calcDepth + 1) * 10000) - Math.min(9999, Math.max(1, Math.round(9999 * ((linkWidth * linkHeight) / $this.linkeditor.bookSurface))));
+
+            if (isNaN(zindex) || isNaN(calcDepth)) {
+                console.warn('error defining depth of link ' + $(this).attr('fb-uid'), calcDepth, $this.findDefaultLinkDepth($(this)), linkWidth, linkHeight, $this.linkeditor.bookSurface);
+            }
+
+            $(this).attr('fb-calc-depth', calcDepth);
+            $(this).attr('fb-calc-zindex', zindex);
+            $(this).css('z-index', zindex);
+        });
+    },
+
+    findDefaultLinkDepth: function (link) {
+        var conf = DEPTH.configs[$(link).attr('fb-type')];
+        var key = $(link).attr('fb-type');
+        var settings = [];
+        $.each(conf, function (k, v) {
+            let val = $(link).attr('fb-' + k);
+            if (k === 'alternative' || k === 'to') {
+                if (key == 6) {
+                    let e = val.split('.');
+                    let ext = e.pop().toLowerCase();
+                    val = (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg'].indexOf(ext) >= 0) ? 'file.jpg' : 'file.zip';
+                }
+            }
+            settings.push(k + '|' + val);
+        });
+        if (settings.length > 0) {
+            key += '/' + settings.join(',');
+        }
+
+        let res = DEPTH.depths[key];
+        if (res === undefined) {
+            console.log('undefined depth key ', key);
+        }
+        return res;
+    },
+
+    clear: function () {
+        $("#linkeditor-links").html('');
+    },
+
+    alignSelection: function (align) {
+        let d = this.getMinMaxSelection(align);
+        if (align === 'left' || align === 'top') {
+            this.getCurrentSelection().each(function () {
+                $(this).attr('fb-' + d.side, d.min);
+            });
+        } else if (align === 'right' || align === 'bottom') {
+            this.getCurrentSelection().each(function () {
+                $(this).attr('fb-' + d.side, d.max - parseFloat($(this).attr('fb-' + d.length)));
+            });
+        } else if (align === 'center' || align === 'middle') {
+            let center = d.min + ((d.max - d.min) / 2);
+            this.getCurrentSelection().each(function () {
+                $(this).attr('fb-' + d.side, center - parseFloat($(this).attr('fb-' + d.length)) / 2);
+            });
+        }
+        this.updateSelectionData([d.side]);
+        this.linkeditor.hasChanged();
+    },
+
+    dimensionSelection: function (dimension, skipChanged) {
+        if (dimension === 'both') {
+            this.dimensionSelection('width', true);
+            this.dimensionSelection('height', true);
+        } else {
+            let d = this.getMinMaxSelection(dimension);
+            this.getCurrentSelection().each(function () {
+                $(this).attr('fb-' + dimension, d.maxl);
+            });
+        }
+        if (skipChanged === undefined || skipChanged !== true) {
+            this.updateSelectionData(['width', 'height']);
+            this.linkeditor.hasChanged();
+        }
+    },
+
+    updateLinkData: function (id, data, updateHTML) {
+        if (LINKS[id] === undefined) {
+            console.warn('Link ' + id + ' not found');
+            return;
+        }
+
+        let htmlLink = updateHTML ? $('.link[fb-uid="' + id + '"]') : false;
+
+        $.each(data, function (k, v) {
+            LINKS[id][k] = v;
+            if (htmlLink) {
+                $(htmlLink).attr('fb-' + k, v);
+            }
+        });
+        this.linkeditor.rulers.updateMagnetValues();
+        this.updateLayers();
+        this.updatePolygonLinks(false);
+    },
+
+    updateSelectionData: function (props) {
+        this.getCurrentSelection().each(function () {
+            let uid = $(this).attr('fb-uid');
+            for (let i = 0; i < props.length; i++) {
+                let prop = props[i];
+                LINKS[uid][prop] = $(this).attr('fb-' + prop);
+            }
+        });
+        this.linkeditor.rulers.updateMagnetValues();
+        this.updateLayers();
+    },
+
+    distributeSelection: function (axis) {
+        let d = this.getMinMaxSelection(axis);
+        let totalLength = d.max - d.min;
+        let lengthSum = 0;
+        let links = [];
+        this.getCurrentSelection().each(function () {
+            let l = parseFloat($(this).attr('fb-' + d.length));
+            links.push({pos: $(this).attr('fb-' + d.side), length: l, link: $(this)});
+            lengthSum += l;
+        });
+        let space = (totalLength - lengthSum) / (links.length - 1);
+        links.sort(function (a, b) {
+            return a.pos - b.pos;
+        });
+        var s = d.min;
+        $.each(links, function (k, link) {
+            $(link.link).attr('fb-' + d.side, s);
+            s += link.length + space;
+        });
+        this.updateSelectionData([d.side]);
+        this.linkeditor.hasChanged();
+    },
+
+    getMinMaxSelection: function (axis) {
+        if (axis === 'left' || axis === 'right' || axis === 'center' || axis === 'width') {
+            axis = 'x';
+        } else if (axis === 'top' || axis === 'bottom' || axis === 'middle') {
+            axis = 'y;'
+        }
+
+        var b = axis === 'x' ? 'left' : 'top';
+        var l = axis === 'x' ? 'width' : 'height';
+        var max = -100000000;
+        var maxl = max;
+        var min = 100000000;
+        var minl = minl;
+        this.getCurrentSelection().each(function () {
+            let vb = parseFloat($(this).attr('fb-' + b));
+            let vl = parseFloat($(this).attr('fb-' + l));
+            min = Math.min(min, vb);
+            minl = Math.min(minl, vl);
+            max = Math.max(max, vb + vl);
+            maxl = Math.max(maxl, vl);
+        });
+        return {min: min, max: max, minl: minl, maxl: maxl, side: b, length: l};
+    },
+
+    coverPage(margin, double) {
+        if (double === undefined) {
+            double = false;
+        }
+        if (margin === undefined) {
+            margin = 0;
+        }
+        var link = this.getCurrentSelection().eq(0);
+        var rect = {
+            x: -margin, y: -margin, width: this.linkeditor.fw + margin * 2, height: this.linkeditor.ph + margin * 2,
+        };
+        if (!this.linkeditor.single && !double) {
+            if (parseFloat($(link).attr('fb-left')) > this.linkeditor.pw) {
+                rect.x = this.linkeditor.pw - margin;
+            }
+            rect.width = this.linkeditor.pw + margin * 2;
+        }
+        link.attr('fb-left', rect.x).attr('fb-top', rect.y).attr('fb-width', rect.width).attr('fb-height', rect.height);
+        this.updateSelectionData(['left', 'top', 'width', 'height']);
+        this.linkeditor.hasChanged();
+    },
+
+    getCurrentState: function () {
+        let data = [];
+        let $this = this;
+        $(".link:not(.pendingCreate):not([fb-width=0]):not([fb-height=0])").each(function () {
+            let l = {};
+            $.each(LINKS[$(this).attr('fb-uid')], function (k, v) {
+                l[k] = $this.linkeditor.form.normalizeFormValue(k, v);
+            });
+            data.push(l);
+        });
+        data.sort(function (a, b) {
+            return a.toString().localeCompare(b.toString());
+        });
+        return JSON.stringify(data);
+    },
+
+    setCurrentState: function (state) {
+        let links = JSON.parse(state);
+        this.clear();
+        var $this = this;
+        var existingUIDs = [];
+        // Update existing and add new links
+        $.each(links, function (k, link) {
+            $this.addLink(link, false);
+            LINKS[link.uid] = link;
+            existingUIDs.push(link.uid);
+        });
+        // Search for missing links and delete them
+        var pages = this.linkeditor.getCurrentPages();
+        var currentPages = [];
+        $.each(pages, function (k, v) {
+            currentPages.push(v.toString());
+        })
+        $.each(LINKS, function (uid, link) {
+            if (currentPages.indexOf(link.page.toString()) >= 0) {
+                if (existingUIDs.indexOf(link.uid) === -1) {
+                    console.log("missing link", LINKS[uid], " deleting");
+                    delete LINKS[uid];
+                }
+            }
+        });
+
+        this.linkeditor.hasChanged(false);
+    },
+
+
+    startRectSelection: function () {
+        $('.link.selected').addClass('selectedBeforeRect');
+        this.rectSelection = this.linkeditor.globalToCanvas(this.linkeditor.mx, this.linkeditor.my);
+    },
+
+    updateRectSelection: function () {
+        if (this.rectSelection === null) {
+            return false;
+        }
+
+        var $this = this;
+        let pos = this.linkeditor.globalToCanvas(this.linkeditor.mx, this.linkeditor.my);
+
+        var css = {};
+        css.width = pos.x - this.rectSelection.x;
+        css.height = pos.y - this.rectSelection.y;
+        css.left = this.rectSelection.x;
+        css.top = this.rectSelection.y;
+        if (css.width < 0) {
+            css.left = pos.x;
+            css.width *= -1;
+        }
+        if (css.height < 0) {
+            css.top = pos.y;
+            css.height *= -1;
+        }
+
+        $("#linkeditor-selectlink-rect").show();
+        $("#linkeditor-selectlink-rect").css(css);
+
+        let selectRect = $("#linkeditor-selectlink-rect").get(0).getBoundingClientRect();
+        $(".link:not(.selectedBeforeRect):not([data-locked=\"1\"])").each(function () {
+            if ($this.linkeditor.utils.intersectRect($(this).get(0).getBoundingClientRect(), selectRect)) {
+                $this.selectLink(this);
+            } else {
+                $this.deselectLink(this);
+            }
+        });
+
+        return true;
+    },
+
+    endRectSelection: function () {
+        if (this.rectSelection === null) {
+            return false;
+        }
+        $(".selectedBeforeRect").removeClass('selectedBeforeRect');
+        $("#linkeditor-selectlink-rect").hide();
+        this.rectSelection = null;
+        return true;
+    },
+
+
+    importFromPDF: function () {
+        var $this = this;
+        var callback = function () {
+            $.ajax({
+                url: '/fluidbook-publication/' + FLUIDBOOK_DATA.id + '/edit/links/import/pdf',
+                success: function (data) {
+                    window.location.reload();
+                },
+            });
+        };
+
+        this.linkeditor.save.saveIfUnsavedChanges(TRANSLATIONS.before_import_links_from_pdf, false, callback);
+    },
+
+    mouseUp: function () {
+        this.endRectSelection();
+        this.stopDragLink();
+        this.stopResizeLink();
+        this.stopMovePolygonPoint();
+        this.cleanPendingCreateLink();
+    },
+
+    mouseDown: function () {
+        if ($('#linkeditor-main').hasClass('polygon')) {
+            this.beginPolygonLine();
+        } else {
+            this.createLinkDrag();
+        }
+    },
+
+    beginPolygonLine: function () {
+        var link;
+        var pos = this.linkeditor.globalToFluidbook(this.linkeditor.mx, this.linkeditor.my, this.linkeditor.single);
+        let polygon;
+        if ($('.pendingPolygonCreate').length === 0) {
+            link = this.duplicateLinkDrag({width: 0, height: 0, left: pos.x, top: pos.y});
+            $(link).addClass('pendingPolygonCreate');
+            polygon = [pos];
+        } else {
+            link = $('.pendingPolygonCreate');
+            polygon = this.getOffsetPolygon(link);
+            polygon.push(pos);
+        }
+
+        this.selectLink($(link));
+        this.updatePolygonLink(link, polygon);
+        this.linkeditor.hasChanged();
+    },
+
+    closePolygonShape: function () {
+        $('.pendingPolygonCreate').removeClass('pendingPolygonCreate');
+    },
+
+    getOffsetPolygon: function (link) {
+        let left = parseFloat($(link).attr('fb-left'));
+        let top = parseFloat($(link).attr('fb-top'));
+        try {
+            let points = JSON.parse($(link).attr('fb-polygon'));
+            let offset = [];
+            $.each(points, function (k, v) {
+                offset.push({x: parseFloat(v.x) + left, y: parseFloat(v.y) + top});
+            });
+            return offset;
+        } catch (e) {
+            return null;
+        }
+    },
+
+    updatePolygonLinks: function (updateData) {
+        if (updateData === undefined) {
+            updateData = true;
+        }
+        let $this = this;
+        $('.link[fb-polygon]').each(function () {
+            let polygon = $this.getOffsetPolygon($(this));
+            if (polygon !== null) {
+                $this.updatePolygonLink($(this), polygon, updateData);
+            } else {
+                $(this).find('svg,.corners').remove();
+                $(this).attr('fb-polygon', null);
+            }
+        });
+    },
+
+    updatePolygonLink: function (link, polygon, updateData) {
+        if (updateData === undefined) {
+            updateData = true;
+        }
+        let $this = this;
+
+        let minx = Number.MAX_VALUE;
+        let maxx = Number.MIN_VALUE;
+        let miny = Number.MAX_VALUE;
+        let maxy = Number.MIN_VALUE;
+
+        $.each(polygon, function (k, pos) {
+            minx = Math.min(minx, pos.x);
+            miny = Math.min(miny, pos.y);
+            maxx = Math.max(maxx, pos.x);
+            maxy = Math.max(maxy, pos.y);
+        });
+
+        let normalizedPolygon = [];
+
+        let w = maxx - minx;
+        let h = maxy - miny;
+
+        $(link).attr('fb-width', w);
+        $(link).attr('fb-height', h);
+        $(link).attr('fb-left', minx);
+        $(link).attr('fb-top', miny);
+
+        let svg = '<svg  preserveAspectRatio="none" viewBox="0 0 ' + w + ' ' + h + '" width="' + w + '" height="' + h + '" style="width: 100%;height:100%;"><polygon points="';
+        let corners = $('<div class="corners"></div>');
+        let clippath = [];
+
+        $.each(polygon, function (k, pos) {
+            let point = {
+                x: $this.linkeditor.utils.roundDimension(pos.x - minx),
+                y: $this.linkeditor.utils.roundDimension(pos.y - miny)
+            };
+            let cx = (point.x / w) * 100;
+            let cy = (point.y / h) * 100;
+            svg += point.x + ',' + point.y + ' ';
+            normalizedPolygon.push(point);
+            $(corners).append('<div class="poly" data-point="' + k + '" style="left:calc(' + cx + '% - 4px);top:calc(' + cy + '% - 4px);"></div>');
+
+            let cs = 1.05;
+            let ccx = ((cx / 100) * 100 * cs) - ((cs * 100) - 100) / 2;
+            let ccy = ((cy / 100) * 100 * cs) - ((cs * 100) - 100) / 2;
+            clippath.push([ccx, ccy]);
+        });
+
+
+        svg += '" fill="currentColor" stroke="currentColor" stroke-width="1.25" fill-opacity="0.25" vector-effect="non-scaling-stroke"></svg>';
+        $(link).css('clip-path', 'polygon(' + this.getConvexClipPath(clippath) + ')');
+        $(link).html(svg);
+        $(link).append(corners);
+        let jsonPolygon = JSON.stringify(normalizedPolygon);
+        $(link).attr('fb-polygon', jsonPolygon);
+        if (updateData) {
+            this.updateLinkData($(link).attr('fb-uid'), {
+                left: minx, top: miny, width: w, height: h, polygon: jsonPolygon
+            });
+        }
+    },
+
+
+    getConvexClipPath: function (points) {
+        const grahamscan = new GrahamScan();
+        grahamscan.setPoints(points);
+        let hull = grahamscan.getHull();
+        let res = [];
+        $.each(hull, function (k, p) {
+            res.push(p[0] + '% ' + p[1] + '%');
+        });
+        return res.join(',');
+    },
+
+    startMovePolygonPoint: function (idx) {
+        let link = this.getFirstLinkInSelection();
+        let polygon = this.getOffsetPolygon(link);
+        this.movePolygonPointPos = {
+            x: this.linkeditor.mx, y: this.linkeditor.my, ox: polygon[idx].x, oy: polygon[idx].y, index: idx
+        };
+        this.setDragOrigValues();
+    },
+
+    stopMovePolygonPoint: function () {
+        if (this.movePolygonPointPos === null) {
+            return;
+        }
+        var $this = this;
+        this.linkeditor.form.updateLinkForm();
+        this.movePolygonPointPos = null;
+        this.updatePolygonLinks();
+        this.linkeditor.hasChanged();
+    },
+
+    movePolygonPoint: function () {
+        if (this.movePolygonPointPos === null) {
+            return;
+        }
+        let $this = this;
+        let f = 1 / (this.linkeditor.fs * this.linkeditor.zoom.zoom);
+        let dx = (this.linkeditor.mx - this.movePolygonPointPos.x) * f;
+        let dy = (this.linkeditor.my - this.movePolygonPointPos.y) * f;
+
+
+        let link = this.getFirstLinkInSelection();
+        let polygon = this.getOffsetPolygon(link);
+        polygon[this.movePolygonPointPos.index].x = this.movePolygonPointPos.ox + dx;
+        polygon[this.movePolygonPointPos.index].y = this.movePolygonPointPos.oy + dy;
+
+        this.updatePolygonLink(link, polygon);
+        $(link).attr('fb-update', '1');
+
+        this.linkeditor.updateFBElements(false);
+        this.linkeditor.save.hasChanged();
+    },
+};
+
+export default LinkeditorLinks;
diff --git a/resources/linkeditor-stable/js/linkeditor.links.lock.js b/resources/linkeditor-stable/js/linkeditor.links.lock.js
new file mode 100644 (file)
index 0000000..6b43396
--- /dev/null
@@ -0,0 +1,60 @@
+function LinkeditorLinksLock(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.locked = [];
+}
+
+LinkeditorLinksLock.prototype = {
+    isLocked: function (uid) {
+        return this.locked.indexOf(uid) >= 0;
+    },
+
+    toggleLock: function (uid) {
+        if (this.isLocked(uid)) {
+            this.unlock([uid]);
+        } else {
+            this.lock([uid]);
+        }
+    },
+
+    lock: function (uid) {
+        for (let i in uid) {
+            this.locked.push(uid[i]);
+        }
+        this.locked = this.linkeditor.utils.array_unique(this.locked);
+        this.update();
+    },
+
+    unlock: function (uid) {
+        for (let i in uid) {
+            let u = this.locked.splice(this.locked.indexOf(uid[i]), 1);
+        }
+        this.locked = this.linkeditor.utils.array_unique(this.locked);
+        this.update();
+    },
+
+    lockSelection: function () {
+        let uid = [];
+        $('.link.selected').each(function () {
+            uid.push($(this).attr('fb-uid'));
+        });
+        this.lock(uid);
+    },
+
+    update: function () {
+        // Unlock layers
+        $('#linkeditor-panel-layers [data-locked="1"]').attr('data-locked', '0');
+        $('#linkeditor-links .link[data-locked="1"]').attr('data-locked', '0');
+
+        for (let i in this.locked) {
+            let uid = this.locked[i];
+            // Lock layer
+            $('input[name="' + uid + '"]').closest('[data-locked]').attr('data-locked', '1');
+            // Unselect link
+            let link = this.linkeditor.links.getLinkById(uid);
+            this.linkeditor.links.deselectLink(link);
+            link.attr('data-locked', '1');
+        }
+    },
+}
+
+module.exports = LinkeditorLinksLock;
diff --git a/resources/linkeditor-stable/js/linkeditor.loader.js b/resources/linkeditor-stable/js/linkeditor.loader.js
new file mode 100644 (file)
index 0000000..09fdaa9
--- /dev/null
@@ -0,0 +1,96 @@
+function LinkeditorLoader(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.preloadTimeouts = [];
+    this.init();
+}
+
+LinkeditorLoader.prototype = {
+
+    init: function () {
+        this.pagesSource = 'pages';
+        this.rasterizePages = this.linkeditor.utils.splitPages(FLUIDBOOK_DATA.settings.rasterizePages);
+        this.vectorPages = this.linkeditor.utils.splitPages(FLUIDBOOK_DATA.settings.vectorPages);
+        this.noCache = '?t=' + (new Date(FLUIDBOOK_DATA.composition_updated_at)).getTime();
+    },
+
+    loadPage: function (p, side) {
+        var container = $("#linkeditor-page-" + side);
+        $(container).attr('data-page', p);
+
+        if (p === 0 || p > FLUIDBOOK_DATA.settings.pages) {
+            $(container).html('');
+        } else {
+            this._loadPage(p, container);
+        }
+
+        this.linkeditor.resize.resizePages();
+
+        this.linkeditor.links.loadLinks(p, side);
+        this.linkeditor.rulers.loadRulers(p, side);
+    },
+
+    togglePagesSource: function () {
+        var $this = this;
+        this.pagesSource = this.pagesSource === 'pages' ? 'thumbnails' : 'pages';
+        this.clearPreloads();
+        $(".linkeditor-page").each(function () {
+            let p = parseInt($(this).attr('data-page'));
+            if (isNaN(p) || p === 0 || p > FLUIDBOOK_DATA.settings.pages) {
+                $(this).html('');
+            } else {
+                $this._loadPage(p, $(this));
+            }
+        });
+        setTimeout(function () {
+            $this.preloadPages();
+        }, 2000);
+    },
+
+    clearPreloads: function () {
+        $.each(this.preloadTimeouts, function (k, timeout) {
+            clearTimeout(timeout);
+        });
+        $("#linkeditor-preload").html('');
+    },
+
+    preloadPages: function () {
+        let j = 1;
+        var $this = this;
+        for (let i = Math.max(1, this.linkeditor.currentPage - 2); i <= Math.min(this.linkeditor.currentPage + 6, FLUIDBOOK_DATA.settings.pages); i++) {
+            if ($('.preload[data-page="' + i + '"]').length >= 1) {
+                continue;
+            }
+            this.preloadTimeouts.push(setTimeout(function () {
+                var c = $('<div class="preload" data-page="i"></div>');
+                $("#linkeditor-preload").append(c);
+                $this._loadPage(i, c, true);
+            }, j * 1500));
+            j++;
+        }
+    },
+
+    _loadPage: function (p, container) {
+        var imageFormat = FLUIDBOOK_DATA.settings.imageFormat;
+        var c = '<div class="contents">';
+        if (this.linkeditor.utils.isSpecialPage(p)) {
+            let data = this.linkeditor.utils.getSpecialPageAssetData(p);
+            c += '<img draggable="false" width="' + data.dim[0] + '" height="' + data.dim[1] + '" class="images" src="' + data.url + '" />';
+        } else {
+            if (this.pagesSource === 'pages') {
+                if (this.rasterizePages.indexOf(p) >= 0) {
+                    c += '<img draggable="false" src="raster_' + p + '.' + imageFormat + this.noCache + '" />';
+                } else if (this.vectorPages.indexOf(p) >= 0) {
+                    c += '<img draggable="false" src="vector_' + p + '.svg' + this.noCache + '" />';
+                } else {
+                    c += '<img draggable="false" class="images" src="images_' + p + '.' + imageFormat + this.noCache + '" />';
+                    c += '<img draggable="false" class="texts" src="texts_' + p + '.svg' + this.noCache + '" />';
+                }
+            } else if (this.pagesSource === 'thumbnails') {
+                c += '<img draggable="false" src="thumbspdf_' + p + '.' + imageFormat + this.noCache + '" />';
+            }
+        }
+        c += '</div>';
+        $(container).html(c);
+    },
+}
+export default LinkeditorLoader;
diff --git a/resources/linkeditor-stable/js/linkeditor.panels.js b/resources/linkeditor-stable/js/linkeditor.panels.js
new file mode 100644 (file)
index 0000000..66c6d5e
--- /dev/null
@@ -0,0 +1,169 @@
+function LinkeditorPanels(linkeditor) {
+    this.linkeditor = linkeditor;
+
+    this.maxWidth = 70;
+    this.minWidth = 7;
+    this.defaultWidth = 15;
+    this.thresholdToggle = 3;
+}
+
+LinkeditorPanels.prototype = {
+    init: function () {
+        var $this = this;
+
+        this.sides = this.linkeditor.settings.get('panels_layout', {left: ['layers', 'versions'], right: ['form']});
+        $.each(this.sides, function (side, panels) {
+            var panelsContainer = $("#linkeditor-" + side + '-panel');
+            panelsContainer.data('width', $this.normalizeWidth($this.linkeditor.settings.get(side + '_width', $this.defaultWidth)));
+
+
+            $.each(panels, function (k, panel) {
+                $("#linkeditor-" + side + " nav").append($("#linkeditor-icon-" + panel));
+                if ($('#linkeditor-panel-' + panel).length > 0) {
+                    panelsContainer.append($('#linkeditor-panel-' + panel));
+                } else {
+                    panelsContainer.append('<div id="linkeditor-panel-' + panel + '"></div>');
+                }
+                $('#linkeditor-panel-' + panel).attr('data-panel', panel).addClass('linkeditor-panel');
+
+            });
+
+            $('#linkeditor-' + side + ' nav a').attr('draggable', false);
+
+            var tool = $this.linkeditor.settings.get(side + '_tool', panels[0]);
+            var open = $this.linkeditor.settings.get(side + '_open', side === 'right');
+
+            if (open) {
+                $this.setPanelState(tool, true);
+            }
+        });
+
+        this.linkeditor.layers.init();
+
+        this.linkeditor.resize.resize();
+        $(document).on('mousedown', ".linkeditor-sidebar .handle", function (e) {
+            $this.linkeditor.setMouseCoordinates(e);
+            $(this).addClass('dragging');
+            return false;
+        });
+
+
+    },
+
+    moveHandle: function () {
+        var $this = this;
+        $(".linkeditor-sidebar .handle.dragging").each(function () {
+            let cssWidth;
+            let sidebar = $(this).closest('.linkeditor-sidebar');
+            let side = sidebar.data('side');
+            if (side === 'left') {
+                cssWidth = $this.linkeditor.mx - 49;
+            } else {
+                cssWidth = $this.linkeditor.resize.ww - 49 - $this.linkeditor.mx;
+            }
+            let panel = $(sidebar).find('.linkeditor-panel-side');
+            let relativeWidth = 100 * (cssWidth / $this.linkeditor.resize.ww);
+
+            let tool = $this.linkeditor.settings.get(side + '_tool');
+            // Open/close when drag&drop
+            if (relativeWidth < $this.thresholdToggle) {
+                // Close
+                $this.setPanelState(tool, false);
+            } else {
+                $this.setPanelState(tool, true);
+            }
+
+            let width = $this.normalizeWidth(relativeWidth);
+            panel.data('width', width);
+            $this.linkeditor.settings.set(side + '_width', width);
+            $this.linkeditor.resize.resize();
+        });
+    },
+
+    normalizeWidth: function (w) {
+        if (w === undefined || w === null || w === '') {
+            w = this.defaultWidth;
+        }
+        return Math.max(this.minWidth, Math.min(w, this.maxWidth));
+    },
+
+    mouseup: function () {
+        $(".linkeditor-sidebar .handle.dragging").removeClass('dragging');
+    },
+
+    resize: function (ww) {
+        $('.linkeditor-panel-side.open').each(function () {
+            var dw = parseFloat($(this).data('width'));
+            if (isNaN(dw)) {
+                return;
+            }
+            var w = (ww / 100) * dw;
+            $(this).css('width', w);
+        });
+        this.linkeditor.versions.resize();
+    },
+
+    toggleVersions: function () {
+        this.togglePanel('versions');
+    },
+    toggleLayers: function () {
+        this.togglePanel('layers');
+    },
+    toggleForm: function () {
+        this.togglePanel('form');
+    },
+    togglePanel: function (panel) {
+        this.setPanelState(panel, 'toggle');
+        this.linkeditor.layers.update();
+    },
+    setPanelState: function (panel, newState) {
+        var $this = this;
+        var panelDiv = $('#linkeditor-panel-' + panel);
+        let container = panelDiv.closest('.linkeditor-panel-side');
+        let side = panelDiv.closest('[data-side]').data('side');
+        var currentState = panelDiv.hasClass('open');
+        if (currentState === newState) {
+            return;
+        }
+        if (newState === 'toggle') {
+            newState = !currentState;
+        }
+        var icon = $("#linkeditor-icon-" + panel);
+
+
+        if (newState) {
+            icon.addClass('active');
+            panelDiv.addClass('open');
+        } else {
+            icon.removeClass('active');
+            panelDiv.removeClass('open');
+        }
+
+        // Close panels on the same side
+        if (newState) {
+            $(icon).closest('nav').find('[data-panel]').each(function () {
+                if ($(this).data('panel') === panel) {
+                    return;
+                }
+                $this.setPanelState($(this).data('panel'), false);
+            });
+        }
+
+        // Check if a panel is open on this side, if not, hide the panel
+        let sideOpen = $(container).children('.open').length > 0;
+        if (sideOpen) {
+            container.addClass('open');
+        } else {
+            container.removeClass('open');
+        }
+
+        if (newState) {
+            this.linkeditor.settings.set(side + '_tool', panel);
+        }
+        this.linkeditor.layers.update();
+        this.linkeditor.settings.set(side + '_open', newState);
+        this.linkeditor.resize.resize();
+    },
+
+};
+export default LinkeditorPanels;
diff --git a/resources/linkeditor-stable/js/linkeditor.popup.js b/resources/linkeditor-stable/js/linkeditor.popup.js
new file mode 100644 (file)
index 0000000..58d0a26
--- /dev/null
@@ -0,0 +1,53 @@
+function LinkeditorPopup(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.init();
+}
+
+LinkeditorPopup.prototype = {
+    init: function () {
+        var $this = this;
+        $(document).on('click', '.popup .close', function () {
+            if ($this.hasOpenPopup()) {
+                $this.close();
+            }
+        });
+    },
+
+    openLinksMove() {
+        this.open('moveLinks');
+    },
+
+    open: function (name) {
+        var clone = $("#popup-templates [data-popup=" + name + "]").clone();
+        $("#popup-holder").append(clone);
+        $("#popup-overlay").addClass('show');
+        this.resize();
+    },
+
+    close: function (name = '') {
+        $("#popup-overlay").removeClass('show');
+        $("#popup-holder").html('');
+    },
+
+    hasOpenPopup() {
+        return $("#popup-overlay.show").length === 1;
+    },
+
+    resize: function () {
+        if (!this.hasOpenPopup()) {
+            return;
+        }
+        var p = $("#popup-holder>div");
+        var w = $(p).outerWidth();
+        var h = $(p).outerHeight();
+        var left = (this.linkeditor.resize.ww - w) / 2;
+        var top = (this.linkeditor.resize.hh - h) / 2;
+        $("#popup-holder").css({
+            width: w,
+            height: h,
+            left: left,
+            top: top,
+        })
+    }
+};
+export default  LinkeditorPopup;
diff --git a/resources/linkeditor-stable/js/linkeditor.resize.js b/resources/linkeditor-stable/js/linkeditor.resize.js
new file mode 100644 (file)
index 0000000..7891216
--- /dev/null
@@ -0,0 +1,117 @@
+function LinkeditorResize(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.init();
+}
+
+LinkeditorResize.prototype = {
+    init: function () {
+        var $this = this;
+        $(window).on('resize', function () {
+            $this.resize();
+            setTimeout(function () {
+                $this.resize();
+            }, 100);
+        });
+        this.updateWindowDimensions();
+    },
+
+    updateWindowDimensions: function () {
+        this.ww = $(window).outerWidth();
+        this.hh = $(window).outerHeight();
+
+    },
+
+    resize: function () {
+
+        let special = this.linkeditor.utils.isSpecialPage();
+        if (this.linkeditor.single || special) {
+            $("#linkeditor").addClass('single').removeClass('double');
+        } else {
+            $("#linkeditor").addClass('double').removeClass('single');
+        }
+
+        this.resizePages();
+        this.updateWindowDimensions();
+        this.resizeMain();
+        this.resizeCanvas();
+        if (this.linkeditor.panels) {
+            this.linkeditor.panels.resize(this.ww);
+        }
+        this.linkeditor.rulers.updateRulers();
+        if (this.linkeditor.popup) {
+            this.linkeditor.popup.resize();
+        }
+    },
+
+    resizePages: function () {
+        let $this = this;
+        let dimCover = $this.linkeditor.utils.getPageDimensions(1);
+        let pw;
+        let ph;
+        let special = false;
+
+        $(".linkeditor-page[data-page]:visible").each(function () {
+            let p = $(this).attr('data-page');
+            let dim = $this.linkeditor.utils.getPageDimensions(p);
+            if (dim === undefined) {
+                dim = dimCover;
+            }
+            pw = dim[0];
+            ph = dim[1];
+
+            if ($this.linkeditor.utils.isSpecialPage(p)) {
+                special = true;
+            } else if ($this.linkeditor.mobileFirst) {
+                pw = dimCover[0];
+            } else {
+                pw = dimCover[0];
+                ph = dimCover[1];
+            }
+            $(this).css({width: pw, height: ph});
+        });
+
+        let fw = pw;
+        if (!this.linkeditor.single && !special) {
+            fw *= 2;
+        }
+        $("#linkeditor-page-right").css({left: this.linkeditor.pw});
+        $("#linkeditor-fluidbook").css({width: fw, height: ph});
+
+    },
+
+    resizeMain: function () {
+        $("#linkeditor-main").css('width', this.ww - $('#linkeditor-left').outerWidth() - $('#linkeditor-right').outerWidth());
+    },
+
+    resizeCanvas: function () {
+        this.linkeditor.canvasRect = $("#linkeditor-canvas").get(0).getBoundingClientRect();
+        this.linkeditor.editorRect = $("#linkeditor-editor").get(0).getBoundingClientRect();
+        var aw = this.linkeditor.canvasRect.width - 30;
+        var ah = this.linkeditor.canvasRect.height - 30;
+
+        if (this.linkeditor.utils.isSpecialPage()) {
+            let dim = this.linkeditor.utils.getPageDimensions();
+            this.linkeditor.fs = Math.min(1, aw / dim[0], ah / dim[1]);
+        } else if (this.linkeditor.mobileFirst) {
+            this.linkeditor.fs = 620 / this.linkeditor.fw;
+        } else {
+            this.linkeditor.fs = Math.min(aw / this.linkeditor.fw, ah / this.linkeditor.fh);
+        }
+
+        let left, top;
+
+        if (this.linkeditor.utils.isSpecialPage()) {
+            let dim = this.linkeditor.utils.getPageDimensions();
+            left = (this.linkeditor.canvasRect.width - (dim[0] * this.linkeditor.fs)) / 2;
+            top = (this.linkeditor.canvasRect.height - (dim[1] * this.linkeditor.fs)) / 2;
+        } else if (!this.linkeditor.mobileFirst) {
+            left = ((this.linkeditor.canvasRect.width * 2) - this.linkeditor.fw * this.linkeditor.fs) / 2;
+            top = ((this.linkeditor.canvasRect.height * 2) - this.linkeditor.fh * this.linkeditor.fs) / 2;
+        } else {
+            left = (this.linkeditor.canvasRect.width - this.linkeditor.fw * this.linkeditor.fs) / 2;
+            top = 75;
+        }
+        $("#linkeditor-fluidbook").css({left: left, top: top, transform: 'scale(' + this.linkeditor.fs + ')'});
+    },
+};
+export default LinkeditorResize;
diff --git a/resources/linkeditor-stable/js/linkeditor.rulers.js b/resources/linkeditor-stable/js/linkeditor.rulers.js
new file mode 100644 (file)
index 0000000..4de32bd
--- /dev/null
@@ -0,0 +1,251 @@
+function LinkeditorRulers(linkeditor) {
+    this.linkeditor = linkeditor;
+
+    this.movingRuler = null;
+    this.rulersMagnetValuesX = [];
+    this.rulersMagnetValuesY = [];
+    this.dividers = [1, 2, 5, 10, 20, 50, 100, 200, 500];
+
+    this.init();
+}
+
+LinkeditorRulers.prototype = {
+    init: function () {
+        let $this = this;
+        $("#linkeditor-canvas").on('scroll', function () {
+            $this.updateRulers();
+        });
+
+        $("#linkeditor-ruler-x").on('mousedown', function (e) {
+            $this.addRuler('y');
+            return false;
+        });
+
+        $("#linkeditor-ruler-y").on('mousedown', function (e) {
+            $this.addRuler('x');
+            return false;
+        });
+
+        $(document).on('mousedown', ".ruler", function (e) {
+            $this.movingRuler = $(this);
+            return false;
+        });
+    },
+
+    mouseUp: function () {
+        this.stopMoveRuler();
+    },
+
+    loadRulers: function (page, side) {
+        let $this = this;
+        $.each(this.getRulersOfPage(page, side), function (uid, ruler) {
+            $this.addRuler(ruler.type, ruler.pos, ruler.uid);
+        });
+        this.linkeditor.links.updateMagnetValues();
+    },
+
+    getRulersOfPage(page, side) {
+        if (page === undefined) {
+            page = this.linkeditor.currentPage;
+        }
+        var res = {};
+        $.each(RULERS, function (uid, ruler) {
+            if (page != ruler.page) {
+                return;
+            }
+            res[uid] = ruler;
+        });
+        return res;
+    },
+
+    updateRulers: function () {
+        // Update rects
+        this.linkeditor.fluidbookRect = $("#linkeditor-fluidbook").get(0).getBoundingClientRect();
+
+        $("#linkeditor-ruler-y,#linkeditor-ruler-x").html('');
+        // Measure of visible fluidbook px at current zoom
+
+        let factor = this.linkeditor.fs / this.linkeditor.zoom.zoom;
+        let visible_w = (this.linkeditor.canvasRect.width / this.linkeditor.fs) / this.linkeditor.zoom.zoom;
+        let visible_h = (this.linkeditor.canvasRect.height / this.linkeditor.fs) / this.linkeditor.zoom.zoom;
+
+        // Find the best divider to have around 12 main divisions
+        let divider = 0;
+        for (let d in this.dividers) {
+            divider = this.dividers[d];
+            let v = visible_h / divider;
+            if (v <= 12) {
+                break;
+            }
+        }
+
+        let divisionSize = divider * this.linkeditor.fs * this.linkeditor.zoom.zoom;
+        // Draw vertical ruler
+        let margin = 100;
+        let nbDivisions = Math.floor(visible_h / divider);
+        let y0 = 16 + this.linkeditor.fluidbookRect.y - this.linkeditor.canvasRect.y;
+
+        let yruler = '<div class="info"><span>1234.12</span></div>';
+        for (let y = -margin; y <= nbDivisions + (margin * 2) + 1; y++) {
+            // Draw subdivision
+            let v = divider * y;
+            let ystart = y0 + (y * divisionSize);
+            if (ystart + divisionSize < 0 || ystart > this.linkeditor.canvasRect.height) {
+                continue;
+            }
+            yruler += '<div class="division" style="top:' + ystart + 'px;height:' + divisionSize + 'px;"><div class="value">' + Math.abs(v) + '</div>';
+            for (let j = 1; j <= 9; j++) {
+                yruler += '<div class="subdivision ' + (j === 5 ? ' middle' : '') + '" style="top:' + ((j * divisionSize) / 10) + 'px;"></div>';
+            }
+            yruler += '</div>';
+        }
+        $("#linkeditor-ruler-y").html(yruler);
+
+        // Draw horizontal ruler
+        nbDivisions = Math.floor(visible_w / divider);
+        let x0 = 16 + this.linkeditor.fluidbookRect.x - this.linkeditor.canvasRect.x;
+        let xruler = '<div class="info"><span></span></div>';
+
+        for (let x = -margin; x <= nbDivisions + (margin * 2) + 1; x++) {
+            // Draw subdivision
+            xruler += this._drawHorizontalSubdiv(x, x0, divider, divisionSize);
+        }
+        // Draw right page horizontal ruler
+        if (!this.linkeditor.single) {
+            x0 = x0 + this.linkeditor.pw * this.linkeditor.fs * this.linkeditor.zoom.zoom;
+            for (let x = 0; x <= nbDivisions + margin + 1; x++) {
+                // Draw subdivision
+                xruler += this._drawHorizontalSubdiv(x, x0, divider, divisionSize);
+            }
+        }
+        $("#linkeditor-ruler-x").html(xruler);
+        this.linkeditor.updateFBElements(true);
+        this.updateMousePositionRulers();
+    },
+
+    _drawHorizontalSubdiv: function (x, x0, divider, divisionSize) {
+        let v = divider * x;
+        let xstart = x0 + (x * divisionSize);
+        if (xstart + divisionSize < 0 || xstart > this.linkeditor.canvasRect.width) {
+            return '';
+        }
+        let res = '<div class="division" style="left:' + xstart + 'px;width:' + divisionSize + 'px;"><div class="value">' + Math.abs(v) + '</div>';
+        for (let i = 1; i <= 9; i++) {
+            let cls = '';
+            if (i === 5) {
+                cls += ' middle';
+            }
+            res += '<div class="subdivision ' + cls + '" style="left:' + ((i * divisionSize) / 10) + 'px;"></div>';
+        }
+        res += '</div>';
+        return res;
+    },
+
+    updateMagnetValues: function () {
+        var $this = this;
+        this.rulersMagnetValuesX = [0, this.linkeditor.pw, this.linkeditor.pw * 2];
+        this.rulersMagnetValuesY = [0, this.linkeditor.ph];
+        $.each(this.linkeditor.links.getLinksOfPage(this.linkeditor.currentPage), function (uid, link) {
+            const left = parseFloat(link.left);
+            const top = parseFloat(link.top);
+            const width = parseFloat(link.width);
+            const height = parseFloat(link.height);
+            $this.rulersMagnetValuesX.push(left, left + width);
+            $this.rulersMagnetValuesY.push(top, top + height);
+        });
+    },
+
+    updateMousePositionRulers: function () {
+        let rulersRect = $("#linkeditor-rulers").get(0).getBoundingClientRect();
+        let rx = this.linkeditor.mx - rulersRect.x;
+        let ry = this.linkeditor.my - rulersRect.y;
+        $("#linkeditor-ruler-x .info").css('left', rx);
+        $("#linkeditor-ruler-y .info").css('top', ry);
+
+        let rrect = $("#linkeditor-rulers").get(0).getBoundingClientRect();
+
+        if (this.linkeditor.mx - rrect.x < 0 || this.linkeditor.my - rrect.y < 0 || this.linkeditor.mx - rrect.x > rrect.width || this.linkeditor.my - rrect.y > rrect.height) {
+            $("#linkeditor-rulers .info").hide();
+            return;
+        }
+
+        let fb = this.linkeditor.globalToFluidbook(this.linkeditor.mx, this.linkeditor.my, true);
+        $("#linkeditor-ruler-y .info span").text(fb.y.toFixed(2));
+        $("#linkeditor-ruler-x .info span").text(fb.x.toFixed(2));
+        $("#linkeditor-rulers .info").css('display', 'inline-block');
+    },
+
+    stopMoveRuler: function () {
+        if (this.movingRuler === null || this.movingRuler === undefined) {
+            return;
+        }
+        this.moveRuler();
+        if ($(this.movingRuler).hasClass('pending-delete')) {
+            this.deleteRuler($(this.movingRuler));
+        }
+        this.movingRuler = null;
+    },
+
+    moveRuler: function () {
+        if (this.movingRuler === null || this.movingRuler === undefined) {
+            return;
+        }
+        let magnet = !key.ctrl;
+        let editorMouse = this.linkeditor.globalToEditor(this.linkeditor.mx, this.linkeditor.my);
+        let fbMouse = this.linkeditor.globalToFluidbook(this.linkeditor.mx, this.linkeditor.my, false);
+        let css = {};
+        let attrs = {'fb-update': '1'};
+        let v, fbv;
+        if ($(this.movingRuler).data('axis') === 'x') {
+            v = editorMouse.x;
+            fbv = attrs['fb-left'] = magnet ? this.linkeditor.utils.magnetize(fbMouse.x, this.rulersMagnetValuesX) : fbMouse.x;
+        } else {
+            v = editorMouse.y;
+            fbv = attrs['fb-top'] = magnet ? this.linkeditor.utils.magnetize(fbMouse.y, this.rulersMagnetValuesY) : fbMouse.y;
+        }
+        if (v < 16) {
+            $(this.movingRuler).addClass('pending-delete');
+        } else {
+            $(this.movingRuler).removeClass('pending-delete');
+        }
+        $(this.movingRuler).css(css).attr(attrs);
+        RULERS[$(this.movingRuler).data('uid')].pos = fbv;
+        this.linkeditor.updateFBElements();
+        this.linkeditor.links.updateMagnetValues();
+    },
+
+    addRuler: function (axis, pos, uid) {
+
+        if (undefined === uid) {
+            uid = this.linkeditor.utils.generateUID();
+            RULERS[uid] = {page: this.linkeditor.currentPage, type: axis, uid: uid};
+            this.linkeditor.hasChanged();
+        }
+        let ruler = $('<div class="ruler" data-uid="' + uid + '" fb-update="1" fb-ref="editor" data-axis="' + axis + '"></div>');
+        if (pos === undefined) {
+            this.movingRuler = ruler;
+        } else {
+            let dim = axis == 'x' ? 'left' : 'top';
+            $(ruler).attr('fb-' + dim, pos);
+        }
+
+        $("#linkeditor-editor").append(ruler);
+        this.moveRuler();
+    },
+
+    deleteRuler: function (ruler) {
+        if (ruler === undefined) {
+            ruler = this.movingRuler;
+        }
+        delete RULERS[$(ruler).data('uid')];
+        $(ruler).remove();
+        this.movingRuler = null;
+        this.linkeditor.links.updateMagnetValues();
+        this.linkeditor.hasChanged();
+    },
+
+    clear: function () {
+        $('#linkeditor-editor .ruler').remove();
+    },
+};
+export default LinkeditorRulers;
diff --git a/resources/linkeditor-stable/js/linkeditor.save.js b/resources/linkeditor-stable/js/linkeditor.save.js
new file mode 100644 (file)
index 0000000..6f9dbc9
--- /dev/null
@@ -0,0 +1,90 @@
+function LinkeditorSave(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.init();
+}
+
+LinkeditorSave.prototype = {
+    init: function () {
+        let $this = this;
+
+        this.automaticSaveFrequency = 1000 * 5 * 60;
+        this.unsavedChanges = false;
+        this.automaticSaveTimeout = null;
+        this.runningAutomaticSaveTimeout = false;
+
+        $(window).on('beforeunload', function () {
+            if ($this.unsavedChanges) {
+                return TRANSLATIONS.warning_unsaved_changes;
+            }
+        })
+    },
+
+    hasChanged: function () {
+        let $this = this;
+        this.unsavedChanges = true;
+        if (this.runningAutomaticSaveTimeout === false) {
+            this.runningAutomaticSaveTimeout = true;
+            this.automaticSaveTimeout = setTimeout(function () {
+                $this.automaticSave();
+            }, this.automaticSaveFrequency);
+        }
+    },
+
+    saveIfUnsavedChanges: function (message, notify, callback) {
+        if (this.unsavedChanges) {
+            this.save(message, false, function () {
+                setTimeout(function () {
+                    callback();
+                }, 1000);
+            });
+        } else {
+            callback();
+        }
+    },
+
+    save: function (message, notify, callback) {
+        if (notify === undefined) {
+            notify = true;
+        }
+        if (callback === undefined) {
+            callback = function () {
+
+            };
+        }
+        var $this = this;
+        if (message === undefined) {
+            message = TRANSLATIONS.manual_save_message;
+        }
+
+        $.ajax({
+            url: '/fluidbook-publication/' + FLUIDBOOK_DATA.id + '/save/links', method: 'post', data: {
+                _method: 'put',
+                message: message,
+                rulers: JSON.stringify(window.RULERS),
+                links: JSON.stringify(window.LINKS),
+            },
+            success: function (data) {
+                if (notify) {
+                    $this.linkeditor.notification(TRANSLATIONS.success_save);
+                }
+                clearTimeout($this.automaticSaveTimeout);
+                $this.unsavedChanges = false;
+                $this.runningAutomaticSaveTimeout = false;
+
+                window.ASSETS = data.assets;
+                $this.linkeditor.versions.setVersions(data.versions);
+                callback();
+            },
+            error: function (jqXHR, status, error) {
+                $this.linkeditor.hasChanged();
+                $this.linkeditor.notification(TRANSLATIONS.error_save + ' : ' + error, 'error');
+            },
+        });
+    },
+
+    automaticSave: function () {
+        this.save(TRANSLATIONS.automatic_save_message);
+    },
+};
+
+export default LinkeditorSave;
diff --git a/resources/linkeditor-stable/js/linkeditor.settings.js b/resources/linkeditor-stable/js/linkeditor.settings.js
new file mode 100644 (file)
index 0000000..1ba80ad
--- /dev/null
@@ -0,0 +1,40 @@
+function LinkeditorSettings(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.saveTimeout = null;
+}
+
+LinkeditorSettings.prototype = {
+    save: function () {
+        var data = {};
+        $.each(SETTINGS, function (k, v) {
+            data['linkeditor_' + k] = v;
+        });
+
+        $.ajax({
+            url: '/toolbox_setting', method: 'POST', data: {settings: data},
+        });
+    },
+
+    get: function (key, defaultValue) {
+        var res = SETTINGS[key];
+        if (res === 'true') {
+            res = true;
+        } else if (res === 'false') {
+            res = false;
+        }
+        if (res === undefined || res === null) {
+            return defaultValue;
+        }
+        return res;
+    },
+
+    set: function (key, value) {
+        let $this = this;
+        SETTINGS[key] = value;
+        clearTimeout(this.saveTimeout);
+        this.saveTimeout = setTimeout(function () {
+            $this.save();
+        }, 3000);
+    }
+};
+export default LinkeditorSettings;
diff --git a/resources/linkeditor-stable/js/linkeditor.toolbar.js b/resources/linkeditor-stable/js/linkeditor.toolbar.js
new file mode 100644 (file)
index 0000000..bf6d620
--- /dev/null
@@ -0,0 +1,87 @@
+function LinkeditorToolbar(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.init();
+}
+
+LinkeditorToolbar.prototype = {
+    init: function () {
+        var $this = this;
+        $("#linkeditor-page-field input").on('change', function () {
+            $this.linkeditor.changePage($(this).val());
+            $(this).blur();
+            return false;
+        });
+
+        $("[data-key]").each(function () {
+            let e = $(this);
+            key($(this).data('key'), function () {
+                if ($(e).is('[data-key-skipintextfields]') && $this.linkeditor.utils.isfocusOnFormItem()) {
+                    return true;
+                }
+                $(e).addClass('hover');
+                $this.linkeditor.runAction($(e).data('action'));
+                setTimeout(function () {
+                    $(e).removeClass('hover')
+                }, 150);
+                return false;
+            });
+        });
+
+        $(document).on('click', '[data-action]', function () {
+            $this.linkeditor.runAction($(this).data('action'), $(this).is('[data-action-args]') ? $(this).data('action-args') : []);
+            return false;
+        });
+
+        $(document).on('submit', 'form.reloadAfterSuccess', function () {
+            $this.submitFormAndReload(this);
+            return false;
+        });
+
+        $(document).on('change', '.importExcel input', function () {
+            $this.submitFormAndReload($(this).closest('form'));
+            return false;
+        });
+
+        $(document).on('click', '#linkeditor-export-latest', function () {
+            var e = $(this);
+            if ($this.linkeditor.save.unsavedChanges) {
+                $this.linkeditor.save.save(TRANSLATIONS.before_export_save_message, false, function () {
+                    $(e).get(0).click();
+                });
+                return false;
+            }
+            return true;
+        })
+
+        this.linkeditor.initTooltips();
+
+        $('#linkeditor-toolbar a').attr('draggable', false);
+    },
+
+    submitFormAndReload: function (form) {
+        this.linkeditor.displayLoader();
+        var $this = this;
+        var callback = function () {
+            $(form).ajaxSubmit(
+                {
+                    dataType: 'json',
+                    success: function (data) {
+                        setTimeout(function () {
+                            window.location.reload();
+                        }, 1000);
+                    }, error: function (data) {
+                        $this.linkeditor.hideLoader();
+                        $this.linkeditor.notification(TRANSLATIONS.error + ' : ' + data.responseJSON.message, 'error');
+                    },
+                }
+            );
+        }
+
+        if ($(form).is('[data-save-before-submit]')) {
+            this.linkeditor.save.saveIfUnsavedChanges($(form).attr('data-save-before-submit'), false, callback)
+        } else {
+            callback();
+        }
+    },
+};
+export default LinkeditorToolbar;
diff --git a/resources/linkeditor-stable/js/linkeditor.undo.js b/resources/linkeditor-stable/js/linkeditor.undo.js
new file mode 100644 (file)
index 0000000..bbbbf24
--- /dev/null
@@ -0,0 +1,130 @@
+function LinkeditorUndo(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.ignoreStatesChanges = false;
+    this.states = [];
+    this.indexes = [];
+    this.init();
+}
+
+LinkeditorUndo.prototype = {
+    init: function () {
+    },
+
+    initState: function () {
+        if (this.states[this.linkeditor.getCurrentPage()] === undefined || this.states[this.linkeditor.getCurrentPage()] === null) {
+            this.indexes[this.linkeditor.getCurrentPage()] = 0;
+            this.pushState();
+        }
+    },
+
+    _states: function () {
+        let redo = false;
+        let undo = false;
+        let nb = this.states[this.linkeditor.getCurrentPage()].length;
+        let idx = this.indexes[this.linkeditor.getCurrentPage()];
+        if (nb > 1) {
+            if (idx < nb) {
+                redo = true;
+            }
+            if (idx > 1) {
+                undo = true;
+            }
+        }
+        return {redo: redo, undo: undo, index: idx, nb: nb};
+    },
+
+    updateIconsStates: function () {
+        let s = this._states();
+        if (s.redo) {
+            $('[data-icon=redo]').removeClass('disabled');
+        } else {
+            $('[data-icon=redo]').addClass('disabled');
+        }
+
+        if (s.undo) {
+            $('[data-icon=undo]').removeClass('disabled');
+        } else {
+            $('[data-icon=undo]').addClass('disabled');
+        }
+    },
+
+    canRedo: function () {
+        return this._states().redo;
+    },
+
+    canUndo: function () {
+        return this._states().undo;
+    },
+
+
+    pushState: function () {
+        if (this.ignoreStatesChanges) {
+            console.log('ignore states changes');
+            return;
+        }
+
+        let index = this.indexes[this.linkeditor.getCurrentPage()];
+        if (index === 0) {
+            this.states[this.linkeditor.getCurrentPage()] = [];
+        }
+
+        let cs = this.linkeditor.links.getCurrentState();
+        let ps = this.states[this.linkeditor.getCurrentPage()][index - 1];
+        if (ps == cs) {
+            // console.log('skipped : no change');
+            return;
+        }
+
+        if (index > 0 && index < this.states[this.linkeditor.getCurrentPage()].length) {
+            this.states[this.linkeditor.getCurrentPage()] = this.states[this.linkeditor.getCurrentPage()].slice(0, index);
+        }
+        this.states[this.linkeditor.getCurrentPage()].push(cs);
+        this.indexes[this.linkeditor.getCurrentPage()]++;
+
+        //console.log('push current index', index, 'states length', this.states[this.linkeditor.getCurrentPage()].length);
+
+        this.updateIconsStates();
+    },
+
+    undo: function () {
+        if (!this.canUndo()) {
+            return;
+        }
+        let index = this.indexes[this.linkeditor.getCurrentPage()];
+        index--;
+        let state = this.states[this.linkeditor.getCurrentPage()][index - 1];
+        this.ignoreStatesChanges = true;
+        this.linkeditor.links.setCurrentState(state);
+        var $this = this;
+        setTimeout(function () {
+            $this.ignoreStatesChanges = false;
+        }, 500);
+        this.indexes[this.linkeditor.getCurrentPage()] = index;
+
+        //console.log('undo : current index', index, 'states length', this.states[this.linkeditor.getCurrentPage()].length);
+
+        this.updateIconsStates();
+    },
+
+    redo: function () {
+        if (!this.canRedo()) {
+            return;
+        }
+        let index = this.indexes[this.linkeditor.getCurrentPage()];
+        let state = this.states[this.linkeditor.getCurrentPage()][index];
+        this.ignoreStatesChanges = true;
+        this.linkeditor.links.setCurrentState(state);
+        var $this = this;
+        setTimeout(function () {
+            $this.ignoreStatesChanges = false;
+        }, 500);
+        index++;
+        this.indexes[this.linkeditor.getCurrentPage()] = index;
+        //console.log('redo : current index', index, 'states length', this.states[this.linkeditor.getCurrentPage()].length);
+
+        this.updateIconsStates();
+    }
+}
+
+
+export default LinkeditorUndo;
diff --git a/resources/linkeditor-stable/js/linkeditor.utils.js b/resources/linkeditor-stable/js/linkeditor.utils.js
new file mode 100644 (file)
index 0000000..de2dc50
--- /dev/null
@@ -0,0 +1,173 @@
+var LinkeditorUtils = function (linkeditor) {
+    this.linkeditor = linkeditor;
+    this.init();
+};
+
+LinkeditorUtils.prototype = {
+    init: function () {
+
+    },
+
+    maxPageHeight: function () {
+
+    },
+
+
+    array_unique:function(arr){
+        return arr.filter((value, index, array) => array.indexOf(value) === index);
+    },
+
+    isSpecialPage: function (page) {
+        if (page === undefined) {
+            page = this.linkeditor.currentPage;
+        }
+        return page.toString().indexOf('link_') === 0 || (THEME[page] !== undefined && THEME[page] !== null);
+    },
+
+    getSpecialPageAssetData: function (page) {
+        if (page.toString().indexOf('link_') === 0) {
+            let asset = this.getLinkImageId(page);
+            return ASSETS[asset];
+        } else {
+            return THEME[page];
+        }
+    },
+
+    getSpecialPageAssetURL: function (page) {
+        return this.getSpecialPageAssetData(page).url;
+    },
+
+    getLinkImageId(page) {
+        return page.toString().substring(5);
+    },
+
+    getPageDimensions(page) {
+        if (page === undefined) {
+            page = this.linkeditor.currentPage;
+        }
+        if (this.isSpecialPage(page)) {
+            if (page.toString().indexOf('link_') === 0) {
+                return ASSETS[this.getLinkImageId(page)]['dim'];
+            } else {
+                return THEME[page]['dim'];
+            }
+        } else {
+            page = parseInt(page);
+        }
+        return FLUIDBOOK_DATA.page_dimensions[page];
+    },
+
+    normalizePage: function (page) {
+        if (this.isSpecialPage(page)) {
+            return page;
+        }
+        page = parseInt(page);
+        if (page % 2 === 1 && !this.linkeditor.single) {
+            page--;
+        }
+        return Math.max(this.linkeditor.single ? 1 : 0, Math.min(page, FLUIDBOOK_DATA.settings.pages));
+    },
+
+    splitPages: function (str) {
+        let res = [];
+        if (str === undefined || str === null) {
+            return res;
+        }
+        str = str.toString();
+        if (str == '') {
+            return res;
+        }
+        let pages = str.split(',');
+        for (let p in pages) {
+            res.push(parseInt(pages[p]));
+        }
+        return res;
+    },
+
+    pct: function (v) {
+        return (v * 100) + '%';
+    },
+
+    generateUID: function () {
+        const length = 12;
+        let result = '';
+        const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
+        const charactersLength = characters.length;
+        for (let i = 0; i < length; i++) {
+            result += characters.charAt(Math.floor(Math.random() * charactersLength));
+        }
+        return result;
+    },
+
+    magnetize: function (value, values, width, onlyCheckOtherSide) {
+        if (onlyCheckOtherSide === undefined) {
+            onlyCheckOtherSide = false;
+        }
+        if (width === undefined) {
+            width = 0;
+        }
+        let sensibility = 8 / (this.linkeditor.zoom.zoom * this.linkeditor.fs);
+        let min = 100000;
+        let magnetValue;
+        let magnetizeOtherSide = false;
+
+        if (values.length === 0) {
+            return value;
+        }
+
+        for (let i in values) {
+            let v = values[i];
+
+            if (!onlyCheckOtherSide) {
+                let diff = Math.abs(v - value);
+                if (diff < min) {
+                    min = diff;
+                    magnetValue = v;
+                    magnetizeOtherSide = false;
+                }
+            }
+
+            let diffOtherSide = Math.abs(v - (value + width));
+            if (diffOtherSide < min) {
+                min = diffOtherSide;
+                magnetValue = v;
+                magnetizeOtherSide = true;
+            }
+        }
+
+        if (min > sensibility) {
+            return value;
+        }
+        if (magnetizeOtherSide) {
+            return magnetValue - width;
+        } else {
+            return magnetValue;
+        }
+    },
+
+    roundDimension: function (v) {
+        return (Math.round(v * 100000) / 100000);
+    },
+
+    intersectRect: function (r1, r2) {
+        return !(r2.left > r1.right || r2.right < r1.left || r2.top > r1.bottom || r2.bottom < r1.top);
+    },
+
+    isfocusOnFormItem: function () {
+        return $(document.activeElement).is('input[type="text"],input[type="email"],input[type="number"],input[type="tel"],input[type="search"],textarea,select');
+    },
+
+    isFirefox: function () {
+        return navigator.userAgent.search("Firefox") >= 0;
+    },
+
+    isSafari: function () {
+        return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+    },
+
+    isWindows: function () {
+        return navigator.userAgent.search('Windows') >= 0;
+    }
+};
+
+export default LinkeditorUtils;
diff --git a/resources/linkeditor-stable/js/linkeditor.versions.js b/resources/linkeditor-stable/js/linkeditor.versions.js
new file mode 100644 (file)
index 0000000..4b9888e
--- /dev/null
@@ -0,0 +1,81 @@
+function LinkeditorVersions(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.init();
+}
+
+LinkeditorVersions.prototype = {
+    init: function () {
+        this.refresh();
+    },
+
+    refresh: function () {
+        var $this = this;
+        $.ajax({
+            url: '/fluidbook-publication/' + FLUIDBOOK_DATA.id + '/edit/links/versions', method: 'get',
+            success: function (data) {
+                $this.setVersions(data);
+            }
+        });
+    },
+
+    setVersions: function (data) {
+        var list = $("#linkeditor-panel-versions-list");
+        list.html('');
+        $.each(data, function (k, version) {
+            let actionArgs = JSON.stringify([version.timestamp]);
+            var item = '<div class="row">';
+            item += '<div class="col1">';
+            item += '<div class="date">' + version.date + '</div>';
+            item += '<div class="name">' + version.name + '</div>';
+            item += '<div class="comments">' + version.comments + '</div>';
+            item += '</div>';
+            item += '<div class="col2">';
+            item += '<div class="links"><b>' + version.links + '</b> links</div>';
+            item += '<div class="rulers"><b>' + version.rulers + '</b> rulers</div>';
+            item += '</div>'
+            item += '<div class="col3">';
+            item += '<div class="actions"><a nohref data-action="versions.restoreVersion" data-action-args="' + actionArgs + '" data-icon="wayback-machine" data-tooltip="' + TRANSLATIONS.restore_version_tooltip + '" draggable="false"></a>' +
+                '<a download="links_' + FLUIDBOOK_DATA.id + '_' + version.timestamp + '.xlsx" href="/fluidbook-publication/' + FLUIDBOOK_DATA.id + '/edit/links/versions/export/' + version.timestamp + '" data-icon="export-links" data-tooltip="' + TRANSLATIONS.export_version_tooltip + '" draggable="false"></a></div>';
+            item += '</div>'
+            item += '</div>'
+            list.append(item);
+        });
+        this.linkeditor.initIcons();
+        this.linkeditor.initTooltips();
+        this.resize();
+    },
+
+    restoreVersion: function (timestamp) {
+        var $this = this;
+        var callback = function () {
+            $this._restoreVersion(timestamp);
+        }
+
+        //Restore links from 2022-12-07 13:37:15
+        this.linkeditor.save.saveIfUnsavedChanges(TRANSLATIONS.before_restore_message,false,callback);
+    },
+
+    _restoreVersion(timestamp) {
+        $.ajax({
+            url: '/fluidbook-publication/' + FLUIDBOOK_DATA.id + '/edit/links/versions/restore/' + timestamp + '',
+            success: function (data) {
+                window.location.reload();
+            },
+        });
+    },
+
+    resize: function () {
+        var w = $("#linkeditor-panel-versions-list").outerWidth();
+        if (w <= 0) {
+            return;
+        }
+        if (w < 300) {
+            $("#linkeditor-panel-versions-list").addClass('small');
+        } else {
+            $("#linkeditor-panel-versions-list").removeClass('small');
+        }
+    },
+
+
+};
+export default LinkeditorVersions;
diff --git a/resources/linkeditor-stable/js/linkeditor.zoom.js b/resources/linkeditor-stable/js/linkeditor.zoom.js
new file mode 100644 (file)
index 0000000..57dc66f
--- /dev/null
@@ -0,0 +1,143 @@
+function LinkeditorZoom(linkeditor) {
+    this.linkeditor = linkeditor;
+    this.init();
+}
+
+LinkeditorZoom.prototype = {
+    init: function () {
+        var $this = this;
+
+        this.zoom = 1;
+        this.zoomdragging = false;
+
+        if (!this.linkeditor.mobileFirst) {
+            $("#linkeditor-main").on('wheel', function (e) {
+                var step = $this.zoom >= 1 ? 0.25 : 0.1;
+                $this.linkeditor.setMouseCoordinates(e);
+                let cursorPositionOnFluidbookBeforeZoom = $this.getFluidbookPositionOfCursor();
+
+                let delta = e.originalEvent.deltaY;
+                if (delta === 0) {
+                    return true;
+                }
+                e.stopPropagation();
+                e.stopImmediatePropagation();
+                e.preventDefault();
+
+                if ($this.setZoom($this.zoom + step * (delta > 0 ? -1 : 1))) {
+                    $this.moveZoomAfterWheelZoom(cursorPositionOnFluidbookBeforeZoom);
+                }
+
+                return false;
+            });
+        }
+    },
+
+    getFluidbookPositionOfCursor: function () {
+        let rect = $("#linkeditor-fluidbook").get(0).getBoundingClientRect();
+        let lx = (this.linkeditor.mx - rect.x) / rect.width;
+        let ly = (this.linkeditor.my - rect.y) / rect.height;
+        return {x: lx, y: ly, width: rect.width, height: rect.height};
+    },
+
+    mouseUp: function () {
+        if (!this.zoomdragging) {
+            return;
+        }
+        this.moveZoomDrag();
+        this.resetZoomDrag();
+    },
+
+    updateMousePosition: function () {
+        if (!$("#linkeditor-main").hasClass('grab')) {
+            this.resetZoomDrag();
+        }
+        if (this.zoomdragging !== false) {
+            this.moveZoomDrag();
+        }
+    },
+
+    resetZoomDrag: function () {
+        $("#linkeditor-main").removeClass('grab').removeClass('grabbing');
+        this.zoomdragging = false;
+    },
+
+    moveZoomDrag: function () {
+        let deltaX = this.linkeditor.mx - this.zoomdragging.x;
+        let deltaY = this.linkeditor.my - this.zoomdragging.y;
+        $("#linkeditor-canvas").scrollTo({
+            top: this.zoomdragging.scrollY - deltaY, left: this.zoomdragging.scrollX - deltaX
+        });
+        this.linkeditor.rulers.updateRulers();
+    },
+
+    moveZoomAfterWheelZoom: function (initialPosition) {
+        let currentPosition = this.getFluidbookPositionOfCursor();
+        var dx = (initialPosition.x - currentPosition.x) * currentPosition.width;
+        var sx = $("#linkeditor-canvas").scrollLeft() + dx;
+
+        var dy = (initialPosition.y - currentPosition.y) * currentPosition.height;
+        var sy = $("#linkeditor-canvas").scrollTop() + dy;
+
+        $("#linkeditor-canvas").scrollTo({left: sx, top: sy});
+        this.linkeditor.rulers.updateRulers();
+    },
+
+
+    normalizeZoom: function (z) {
+        if (this.linkeditor.mobileFirst) {
+            return 1;
+        }
+        return Math.max(0.5, Math.min(8, z));
+    },
+
+    setZoom: function (z, force) {
+        let $this = this;
+        z = this.normalizeZoom(z);
+
+        this.zoom = z;
+
+        let cw2, ch2, zh;
+        if (this.linkeditor.utils.isSpecialPage()) {
+            cw2 = this.linkeditor.canvasRect.width;
+            zh = this.linkeditor.canvasRect.height;
+        } else if (this.linkeditor.mobileFirst) {
+            cw2 = this.linkeditor.canvasRect.width;
+            zh = (this.linkeditor.getCurrentPageHeight() * this.linkeditor.fs) + 150;
+        } else {
+            cw2 = this.linkeditor.canvasRect.width * 2;
+            ch2 = this.linkeditor.canvasRect.height * 2;
+            zh = ch2;
+            if (this.zoom < 1) {
+                zh *= this.zoom;
+            }
+        }
+
+        $("#linkeditor-canvas").attr('data-z', this.zoom);
+        $("#linkeditor-zoom").css({
+            transform: 'scale(' + this.zoom + ')', overflow: 'visible',
+            width: cw2,
+            maxWidth: cw2,
+            minWidth: cw2,
+            height: zh,
+            minHeight: zh,
+            maxHeight: zh
+        });
+
+        setTimeout(function () {
+            if (this.zoom === 1) {
+                $this.resetZoomDrag();
+            }
+            $this.linkeditor.rulers.updateRulers();
+        }, 10);
+        return true;
+    },
+
+    reset: function () {
+        this.setZoom(1);
+        var top = this.linkeditor.mobileFirst ? 0 : '50%';
+        $("#linkeditor-canvas").scrollTo({top: top, left: '50%'});
+        this.resetZoomDrag();
+    },
+};
+export default LinkeditorZoom;
diff --git a/resources/linkeditor-stable/style/inc/_contextmenu.sass b/resources/linkeditor-stable/style/inc/_contextmenu.sass
new file mode 100644 (file)
index 0000000..30c6582
--- /dev/null
@@ -0,0 +1,11 @@
+.context-menu-list
+    z-index: 1000001 !important
+
+.context-menu-item
+    kbd
+        float: right
+        margin-left: 20px
+        border-radius: 2px
+        border: 1px solid currentColor
+        padding: 2px 4px
+        opacity: 0.6
diff --git a/resources/linkeditor-stable/style/inc/_form.sass b/resources/linkeditor-stable/style/inc/_form.sass
new file mode 100644 (file)
index 0000000..9c6fe12
--- /dev/null
@@ -0,0 +1,278 @@
+body
+    --form-text-color: #5d5d5d
+    --field-background: #fff
+    --field-color: #111
+    --field-border: #aaa
+
+    @include dark-theme
+        --form-text-color: #aaa
+        --field-background: #000
+        --field-color: #ccc
+        --field-border: #777
+
+textarea, input[type="text"], input[type="number"], input[type="email"], input[type="url"], input[type="tel"]
+    font-family: $font
+    font-weight: 400
+    color: var(--field-color)
+    background-color: var(--field-background)
+    border: 1px solid var(--field-background)
+    border-radius: 3px
+    font-size: 13px
+    transition: box-shadow 500ms, border 500ms
+    appearance: none
+
+    &:focus
+        border: 1px solid var(--field-border)
+        box-shadow: 0 0 8px rgba(0, 0, 0, 0.3)
+
+input[type="checkbox"]
+    margin-right: 8px
+    appearance: none
+    position: relative
+    cursor: pointer
+    color: var(--form-text-color)
+
+    &::before
+        content: ''
+        display: inline-block
+        width: 14px
+        height: 14px
+        border: 1px solid currentColor
+        border-radius: 2px
+        vertical-align: baseline
+
+    &:checked
+        &::after
+            content: ''
+            display: block
+            width: 12px
+            height: 12px
+            position: absolute
+            top: 1px
+            left: 1px
+            clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%)
+            background-color: currentColor
+
+
+input[type=number]
+    &, &:hover
+        appearance: textfield
+
+        &::-webkit-inner-spin-button, &::-webkit-outer-spin-button
+            -webkit-appearance: none
+            margin: 0
+            visibility: hidden
+
+button
+    font-family: $font
+    font-weight: 500
+    font-size: 15px
+    padding: 5px 10px
+    border: 1px solid var(--field-border)
+    border-radius: 4px
+    color: var(--field-color)
+    background-color: var(--field-background)
+    cursor: pointer
+
+.select2-hidden-accessible
+    position: fixed !important
+
+#linkeditor-form-templates
+    display: none
+
+.linkeditor-linktype
+    &::before
+        display: inline-block
+        width: 12px
+        height: 12px
+        border-radius: 2px
+        margin: 2px 10px 0 0
+        content: ""
+        vertical-align: top
+        position: relative
+
+
+#linkeditor-panel-form
+
+
+    padding: 12px
+    font-size: 13px
+
+    .select2-container--bootstrap
+        font-size: 13px
+        font-weight: 400
+
+        hr
+            border: 0
+            height: 1px
+            background-color: var(--form-text-color)
+            margin: 5px 0
+
+        &, .select2-selection
+            color: var(--form-text-color)
+            background-color: var(--field-background)
+            border-color: var(--field-background)
+            border-radius: 4px
+            box-shadow: none
+
+        &.select2-container--focus .select2-selection, &.select2-container--open .select2-selection
+            box-shadow: 0 0 8px rgba(0, 0, 0, 0.1)
+            border-color: var(--field-border)
+
+    h3
+        font-size: 16px
+        color: var(--form-text-color)
+        text-transform: uppercase
+        padding-top: 15px
+        border-top: 1px solid var(--form-text-color)
+        margin-top: 20px
+
+    p.help-block
+        color: var(--form-text-color)
+        font-size: 11px
+        padding-top: 2px
+        white-space: normal
+
+    .freefile-file
+        position: relative
+
+        &.loading
+            &::after
+                background-image: url("/images/linkeditor/dots-animated.svg")
+                @include dark-theme
+                    background-image: url("/images/linkeditor/dots-dark-animated.svg")
+
+            input
+                pointer-events: none
+                cursor: wait
+
+        &::after
+            content: ""
+            position: absolute
+            display: block
+            padding: 6px
+            right: 0
+            top: 6px
+            width: 25px
+            height: 25px
+            background-image: url("/images/linkeditor/dots.svg")
+            color: var(--field-color)
+            box-sizing: border-box
+            pointer-events: none
+            font-size: 17px
+            @include dark-theme
+                background-image: url("/images/linkeditor/dots-dark.svg")
+
+        input[type=file]
+            position: absolute
+            right: 0
+            width: 35px
+            opacity: 0
+            height: 100%
+            cursor: pointer
+
+        a.upload
+            position: absolute
+            right: 0
+            width: 35px
+            opacity: 0
+            height: 100%
+            cursor: pointer
+            z-index: 2
+
+
+    .input-group
+        position: relative
+
+        .input-group-append
+            position: absolute
+            height: 100%
+            top: 0
+            right: 0
+            pointer-events: none
+            padding: 8px
+
+    textarea, input[type="text"], input[type="number"], input[type="email"], input[type="url"], input[type="tel"]
+        height: 34px
+        padding: 8px
+        width: 100%
+
+    textarea
+        height: auto
+        min-height: 150px
+        max-width: 100%
+        min-width: 100%
+
+    span
+        font-weight: 400
+        font-size: 13px
+        color: var(--form-text-color)
+
+    label
+        display: block
+        font-weight: 600
+        font-size: 12px
+        color: var(--form-text-color)
+        margin: 8px 0 5px 0
+
+    .checkbox
+        margin: 4px 0 5px 0
+
+        position: relative
+        top: 6px
+
+        label
+            vertical-align: baseline
+            display: inline-block
+            position: relative
+            top: -4px
+            margin: 0
+            cursor: pointer
+
+        span
+            margin-left: 5px
+
+
+    #group_position, #group_dimensions, #group_transform
+        margin-top: 5px
+
+        h4
+            font-size: 12px
+            font-weight: 700
+            color: var(--form-text-color)
+            grid-column: 1 / 3
+            grid-row: 1 / 2
+            margin-bottom: 5px
+
+        display: grid
+        grid-template-columns: repeat( 2, 1fr)
+        grid-gap: 2px 25px
+
+        label
+            display: inline-block
+            width: 20px
+
+        input
+            width: calc(100% - 20px)
+
+        [data-name="rot"]
+            label
+                display: block
+
+            .input-group
+                .input-group-append
+                    right: 0
+
+        .input-group
+            display: inline-block
+
+            .input-group-append
+                right: 20px
+
+
+    #group_transform
+        input
+            width: 100%
+
+.select2-container--bootstrap .select2-results > .select2-results__options
+    max-height: 350px !important
diff --git a/resources/linkeditor-stable/style/inc/_layers.sass b/resources/linkeditor-stable/style/inc/_layers.sass
new file mode 100644 (file)
index 0000000..4ce410a
--- /dev/null
@@ -0,0 +1,74 @@
+#linkeditor-panel-layers
+    font-size: 11px
+    color: $color
+    user-select: none
+
+    @include dark-theme
+        color: $color-dark
+
+    h3
+        border-bottom: 1px solid currentColor
+        padding: 3px 10px
+        font-style: italic
+        font-weight: 500
+        background-color: rgba(0, 0, 0, 0.45)
+        color: $color-dark
+        @include dark-theme
+            background-color: rgba(255, 255, 255, 0.45)
+            color: $color
+
+    .layer
+        position: relative
+
+        label
+            display: block
+            border-bottom: 1px solid currentColor
+            padding: 5px 10px
+            cursor: pointer
+
+
+            span
+                display: inline-block
+                position: absolute
+                right: 28px
+                background-color: rgba(255, 255, 255, 0.5)
+                color: #000
+                border-radius: 2px
+                padding: 2px
+                font-family: "Courier New", Courier, monospace
+
+            input
+                color: #fff
+                position: relative
+                top: 2px
+
+                &::before
+                    border: 0
+
+        &[data-locked="1"]
+            label
+                pointer-events: none
+
+            a.lock
+                svg
+                    display: block
+
+        a.lock
+            display: block
+            position: absolute
+            top: 5px
+            right: 6px
+            border-radius: 2px
+            width: 16px
+            height: 16px
+            padding: 3px 4px 0
+            color: rgba(0, 0, 0, 0.6)
+            background-color: rgba(0, 0, 0, 0.15)
+
+            @include dark-theme
+                color: rgba(255, 255, 255, 0.8)
+                background-color: rgba(255, 255, 255, 0.25)
+
+
+            svg
+                display: none
diff --git a/resources/linkeditor-stable/style/inc/_links.sass b/resources/linkeditor-stable/style/inc/_links.sass
new file mode 100644 (file)
index 0000000..24c38cd
--- /dev/null
@@ -0,0 +1,103 @@
+@import "variables"
+
+#linkeditor-links
+    .link
+
+        &[data-locked="1"]
+            pointer-events: none
+
+        #linkeditor-main.grab &
+            pointer-events: none
+
+        box-sizing: border-box
+        position: absolute
+        border: currentColor solid 1px
+        cursor: cell
+
+        #linkeditor-main.dropfile &.dropfile
+            border-style: dashed
+
+        &.dropfile.dragging
+            border-width: 3px
+            border-style: dashed
+
+        &.pendingCreate
+            opacity: 0
+
+        &.selected
+            cursor: move
+            z-index: 1000000 !important
+
+            .corners
+                visibility: visible
+
+        &[fb-polygon^="["]
+            background-color: transparent !important
+            border-style: none
+
+            &.pendingPolygonCreate
+                clip-path: none !important
+
+        .corners
+            visibility: hidden
+            position: absolute
+            top: 0
+            left: 0
+            width: 100%
+            height: 100%
+
+            > div
+                position: absolute
+                border: 1px solid currentColor
+                background-color: #fff
+                width: 8px
+                height: 8px
+
+                &.nw
+                    cursor: nw-resize
+
+                &.n
+                    cursor: n-resize
+
+                &.ne
+                    cursor: ne-resize
+
+                &.e
+                    cursor: e-resize
+
+                &.se
+                    cursor: se-resize
+
+                &.s
+                    cursor: s-resize
+
+                &.sw
+                    cursor: sw-resize
+
+                &.w
+                    cursor: w-resize
+
+                &.n, &.nw, &.ne
+                    top: -4px
+
+                &.e, &.w
+                    top: calc(50% - 4px)
+
+                &.sw, &.s, &.se
+                    bottom: -4px
+
+                &.nw, &.w, &.sw
+                    left: -4px
+
+                &.ne, &.e, &.se
+                    right: -4px
+
+                &.n, &.s
+                    left: calc(50% - 4px)
+
+                &.poly
+                    cursor: crosshair
+                    border-radius: 50%
+
+    position: relative
+    z-index: 500
diff --git a/resources/linkeditor-stable/style/inc/_mixins.sass b/resources/linkeditor-stable/style/inc/_mixins.sass
new file mode 100644 (file)
index 0000000..3700d4a
--- /dev/null
@@ -0,0 +1,20 @@
+@mixin tinyscrollbars
+    &::-webkit-scrollbar
+        width: 6px
+        height: 6px
+
+    &::-webkit-scrollbar-track
+        background: transparent
+
+        &:hover
+            background-color: #000
+
+    &::-webkit-scrollbar-thumb
+        background-color: #aaa
+        border: 1px solid #333
+        border-radius: 20px
+
+@mixin dark-theme
+    @media (prefers-color-scheme: dark)
+        &
+            @content
diff --git a/resources/linkeditor-stable/style/inc/_panels.sass b/resources/linkeditor-stable/style/inc/_panels.sass
new file mode 100644 (file)
index 0000000..5509aca
--- /dev/null
@@ -0,0 +1,91 @@
+.linkeditor-sidebar
+    min-width: $sidebar-icons-width
+    background-color: #EBECEE
+    position: relative
+
+    @include dark-theme
+        background-color: #444
+
+    nav
+        position: absolute
+        left: 0
+        width: $sidebar-icons-width
+        height: 100%
+
+        [data-icon]
+            display: block
+            vertical-align: top
+            height: 30px
+            width: 30px
+            padding: 5px
+            border-radius: 5px
+            margin: 6px 0 0 7px
+            text-align: center
+            color: $toolbar-color
+            @include dark-theme
+                color: $toolbar-color-dark
+
+            &:hover, &.hover, &.active
+                background-color: #fff
+                @include dark-theme
+                    background-color: #000
+
+            svg
+                position: relative
+                top: 1px
+                height: 18px
+                width: auto
+
+
+    .handle
+        position: absolute
+        top: 0
+        right: 0
+        width: $sidebar-handle-width
+        height: 100%
+        background-color: #aaa
+        cursor: col-resize
+        z-index: 1
+
+        @include dark-theme
+            background-color: #666
+
+
+    .linkeditor-panel-side
+        display: none
+        margin-left: $sidebar-icons-width
+        margin-right: $sidebar-handle-width
+
+        overflow-x: hidden
+        @include tinyscrollbars
+
+        background: $panel-background
+        height: 100%
+        @include dark-theme
+            background-color: #333
+
+        &.open
+            display: block
+
+        .linkeditor-panel
+            display: none
+            &.open
+                display: block
+
+    &#linkeditor-right
+        border-width: 0 0 0 2px
+
+        nav
+            left: auto
+            right: 0
+
+        [data-icon]
+            margin-left: 9px
+
+        .handle
+            right: auto
+            left: 0
+
+        .linkeditor-panel-side
+            margin-left: $sidebar-handle-width
+            margin-right: $sidebar-icons-width
diff --git a/resources/linkeditor-stable/style/inc/_popup.sass b/resources/linkeditor-stable/style/inc/_popup.sass
new file mode 100644 (file)
index 0000000..2dd32b7
--- /dev/null
@@ -0,0 +1,65 @@
+#popup-templates
+    display: none
+
+#popup-overlay
+    background-color: rgba(0, 0, 0, 0.5)
+    position: absolute
+    top: 0
+    left: 0
+    opacity: 0
+    pointer-events: none
+    transition: opacity 350ms
+    width: 100%
+    height: 100%
+    z-index: 10000000
+
+    &.show,
+    &.unavailable
+        opacity: 1
+        pointer-events: auto
+
+    #popup-holder
+        position: absolute
+        max-width: 100%
+        max-height: 100%
+
+        .popup
+            background-color: #dbdddf
+            color: #5D5D5D
+            font-size: 13px
+            padding: 20px
+            box-shadow: 0 0 20px rgba(0,0,0,0.5)
+
+            h2
+                font-size: 16px
+                padding-bottom: 10px
+                font-weight: 500
+
+            a.close
+                display: block
+                position: absolute
+                top: 20px
+                right: 20px
+                width: 15px
+                height: 15px
+                cursor: pointer
+
+            p
+                margin: 12px 0
+
+            p.button
+                text-align: right
+                margin: 20px 0 0 0
+
+
+            @include dark-theme
+                background-color: #333
+                color: #d5d5d5
+
+            &[data-popup="moveLinks"]
+                input
+                    &[type="text"],&[type="number"]
+                        width: 50px
+                        height: 20px
+                        margin: 0 5px
+                        padding: 4px
diff --git a/resources/linkeditor-stable/style/inc/_rulers.sass b/resources/linkeditor-stable/style/inc/_rulers.sass
new file mode 100644 (file)
index 0000000..9bdfe17
--- /dev/null
@@ -0,0 +1,198 @@
+@import "variables"
+
+.ruler
+    position: absolute
+    top: 0
+    left: 0
+    z-index: 400
+    border-width: 0
+    border-color: #0ff
+    border-style: solid
+
+    &:after
+        position: absolute
+        content: ""
+        display: block
+        height: 100%
+        width: 100%
+
+    &.pending-delete
+        border-color: #f00 !important
+        z-index: 1100
+
+    &:hover
+        border-color: #0f0
+
+    &[data-axis="x"]
+        width: 0px
+        height: calc(100% - $rulers-size)
+        border-left-width: 1px
+        cursor: col-resize
+        top: $rulers-size
+
+        &:after
+            left: $ruler-margin*-1
+            width: $ruler-margin*2
+
+    &[data-axis="y"]
+        border-bottom-width: 1px
+        height: 0px
+        width: calc(100% - $rulers-size)
+        left: $rulers-size
+
+        &:after
+            top: $ruler-margin*-1
+            height: $ruler-margin*2
+            cursor: row-resize
+
+#linkeditor-rulers
+    $rulers-color: #333
+    $rulers-color-dark: #eee
+
+    @include dark-theme
+        color: $rulers-color-dark
+
+
+    color: $rulers-color
+    position: absolute
+    top: 0px
+    left: 0px
+    width: 100%
+    height: 100%
+
+    #linkeditor-ruler-corner
+        position: absolute
+        top: 0px
+        left: 0px
+        width: $rulers-size
+        height: $rulers-size
+        z-index: 5
+        background-color: #aaa
+        @include dark-theme
+            background-color: #666
+
+
+    .ruler-bar
+        overflow: hidden
+        position: absolute
+        left: 0
+        top: 0
+        z-index: 1000
+
+        .info
+            position: absolute
+            top: 0
+            left: 0
+            z-index: 3
+            font-size: 12px
+            line-height: 8px
+            display: none
+
+            span
+                display: block
+                position: absolute
+                top: 0
+                left: 0
+                background-color: #fff
+                color: #000
+                @include dark-theme
+                    background-color: #000
+                    color: #fff
+
+                padding: 3px
+
+
+        .division
+            pointer-events: none
+            position: absolute
+            background: #fff
+            @include dark-theme
+                background-color: #000
+
+
+            .value
+                position: absolute
+                font-size: 12px
+
+
+            .subdivision
+                position: absolute
+
+
+    #linkeditor-ruler-x
+        height: $rulers-size
+        width: 100%
+
+        .info
+            height: $rulers-size
+            border-left: 1px dotted $rulers-color
+            @include dark-theme
+                border-color: $rulers-color-dark
+
+
+        .division, .subdivision
+            width: 0px
+            border-left: 1px solid $rulers-color
+            @include dark-theme
+                border-color: $rulers-color-dark
+
+
+        .division
+            height: $rulers-size
+
+            .value
+                bottom: 0px
+                left: 3px
+
+
+        .subdivision
+            bottom: 0
+            height: 2px
+
+            &.middle
+                height: 5px
+
+
+    #linkeditor-ruler-y
+        width: $rulers-size
+        height: 100%
+
+        .info
+            width: $rulers-size
+            border-bottom: 1px dotted $rulers-color
+            @include dark-theme
+                border-color: $rulers-color-dark
+
+
+            span
+                transform-origin: 0 0
+                transform: rotate(270deg)
+
+
+        .division, .subdivision
+            height: 0px
+            border-bottom: 1px solid $rulers-color
+            @include dark-theme
+                border-color: $rulers-color-dark
+
+
+        .division
+            width: $rulers-size
+
+            .value
+                text-align: center
+                max-width: $rulers-size
+                word-wrap: break-word
+                white-space: normal
+                line-height: 10px
+                letter-spacing: 30px
+                top: 3px
+                left: 3px
+
+
+            .subdivision
+                right: 0
+                width: 2px
+
+                &.middle
+                    width: 5px
diff --git a/resources/linkeditor-stable/style/inc/_toolbar.sass b/resources/linkeditor-stable/style/inc/_toolbar.sass
new file mode 100644 (file)
index 0000000..e017ce1
--- /dev/null
@@ -0,0 +1,125 @@
+#linkeditor-toolbar
+    background-color: #dbdddf
+
+    @include dark-theme
+        background-color: #444
+
+    color: $toolbar-color
+    @include dark-theme
+        color: $toolbar-color-dark
+
+    height: $toolbar-height
+    padding: 5px
+
+
+    nav
+        padding: 2px
+        display: inline-block
+        height: 26px
+        vertical-align: top
+        width: 33%
+
+        &#linkeditor-toolbar-center
+            text-align: center
+
+        &#linkeditor-toolbar-right
+            text-align: right
+
+    div
+        display: inline-block
+        font-size: $font-size
+        vertical-align: top
+
+    .separator
+        width: 0
+        height: 26px
+        margin: 0 2px
+        border-left: 1px solid $toolbar-color
+
+    div[data-icon]
+        position: relative
+        cursor: pointer
+
+        form.importExcel
+            input
+                position: absolute
+                opacity: 0
+                width: 100%
+                height: 100%
+                z-index: 1
+                cursor: pointer
+                top: 0
+                left: 0
+
+    #linkeditor-page-field
+        padding: 2px 10px
+        background-color: #fff
+        @include dark-theme
+            background-color: #000
+            border-color: #333
+
+        border: 1px solid #ccc
+        border-radius: 5px
+        cursor: text
+        font-size: 13px
+        user-select: none
+        margin: 2px 8px
+        font-weight: 600
+
+        input
+            vertical-align: top
+            text-align: right
+            border: 0
+            background-color: transparent
+            width: 20px
+            color: $toolbar-color
+            @include dark-theme
+                color: $toolbar-color-dark
+
+            font-size: 13px
+            font-weight: 600
+            position: relative
+
+            &:hover, &:focus
+                outline: 0
+                border: 0
+
+    [data-icon]
+        display: inline-block
+        vertical-align: top
+        height: 26px
+        min-width: 26px
+        padding: 3px
+        border-radius: 5px
+        margin: 0 3px
+        text-align: center
+        color: $toolbar-color
+        @include dark-theme
+            color: $toolbar-color-dark
+
+        &.disabled
+            &:hover, &.hover, &.active
+                background-color: transparent
+                @include dark-theme
+                    background-color: transparent
+
+            color: $toolbar-color-disabled
+            @include dark-theme
+                color: $toolbar-color-disabled-dark
+
+        &:hover, &.hover, &.active
+            background-color: #fff
+            @include dark-theme
+                background-color: #000
+
+        &.arrow
+            padding: 1px
+
+            svg
+                height: 16px
+
+        svg
+            position: relative
+            top: 1px
+            height: 18px
+            width: auto
diff --git a/resources/linkeditor-stable/style/inc/_variables.sass b/resources/linkeditor-stable/style/inc/_variables.sass
new file mode 100644 (file)
index 0000000..2246963
--- /dev/null
@@ -0,0 +1,18 @@
+$font: Montserrat, sans-serif
+$font-size: 16px
+$sidebar-icons-width: 46px
+$sidebar-handle-width: 3px
+$rulers-size: 16px
+$ruler-margin: 2px
+
+$toolbar-height: 40px
+$toolbar-color: #5d5d5d
+$toolbar-color-dark: #bbb
+
+$toolbar-color-disabled: #bbb
+$toolbar-color-disabled-dark: #666
+
+$panel-background: #dcdcdc
+
+$color: #5d5d5d
+$color-dark: #bbb
diff --git a/resources/linkeditor-stable/style/inc/_versions.sass b/resources/linkeditor-stable/style/inc/_versions.sass
new file mode 100644 (file)
index 0000000..8ca7441
--- /dev/null
@@ -0,0 +1,77 @@
+#linkeditor-panel-versions
+    padding: 5px 10px
+    user-select: none
+
+#linkeditor-panel-versions-list
+    font-size: 12px
+    width: 100%
+    border-collapse: collapse
+    color: $color
+
+    $icon-size: 18px
+
+    @include dark-theme
+        color: $color-dark
+
+    .row
+        padding: 5px 0
+        vertical-align: top
+        border-bottom: 1px solid currentColor
+
+
+        >div
+            display: inline-block
+            vertical-align: top
+
+        .date
+            font-weight: bold
+        .comments
+            font-style: italic
+
+        .col1
+            width: calc(100% - 125px)
+
+        .col2
+            width: 70px
+
+        .col3
+            width: 55px
+            vertical-align: middle
+            padding-top: 5px
+            a
+                width: $icon-size
+                height: $icon-size
+                margin-left: 7px
+                color: currentColor
+                cursor: pointer
+
+                svg
+                    width: $icon-size
+                    height: $icon-size
+
+    &.small
+        .row
+            position: relative
+            > div
+                display: block
+            .rulers, .links
+                display: inline
+            .links
+                &::after
+                    content: ', '
+            .col1, .col2
+                width: calc(100% - 25px)
+                overflow: hidden
+            .col3
+                position: absolute
+                top: 10px
+                right: 10px
+                width: $icon-size
+                padding: 0
+                a
+                    display: block
+                    margin-bottom: 10px
+
+
+
+
diff --git a/resources/linkeditor-stable/style/style.sass b/resources/linkeditor-stable/style/style.sass
new file mode 100644 (file)
index 0000000..d43720f
--- /dev/null
@@ -0,0 +1,198 @@
+@import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap')
+@import "inc/_variables"
+@import "inc/_mixins"
+
+*
+    margin: 0
+    padding: 0
+    box-sizing: border-box
+
+    &:focus
+        outline: 0
+
+
+body
+    font-family: "Montserrat", sans-serif
+    background-color: #ebecee
+    @include dark-theme
+        background-color: #333
+
+img, .division, .info
+    user-select: none
+    user-drag: none
+
+body, #linkeditor, html
+    height: 100%
+    width: 100%
+    overflow: hidden
+
+#linkeditor
+    white-space: nowrap
+    font-size: 0
+    overflow: hidden
+
+    .when-selection-2, .when-selection-3
+        display: inline-block
+
+    &[data-selection-count="0"], &[data-selection-count="1"]
+        .when-selection-2, .when-selection-3
+            display: none
+
+    &[data-selection-count="2"]
+        .when-selection-3
+            display: none
+
+    .linkeditor-sidebar, #linkeditor-main
+        display: inline-block
+        height: 100%
+        vertical-align: top
+        text-align: left
+
+    #linkeditor-main
+        &.grab
+            cursor: grab
+
+        &.grabbing
+            cursor: grabbing
+
+        &.polygon
+            cursor: crosshair
+
+            .link:not(.pendingPolygonCreate)
+                pointer-events: none
+
+
+        #linkeditor-duplicatelink-overlay, #linkeditor-selectlink-overlay
+            display: none
+            position: absolute
+            z-index: 700
+            top: $rulers-size
+            left: $rulers-size
+            height: calc(100% - $rulers-size)
+            max-height: calc(100% - $rulers-size)
+            width: calc(100% - $rulers-size)
+            max-width: calc(100% - $rulers-size)
+
+        &.duplicate
+            #linkeditor-duplicatelink-overlay
+                display: block
+                cursor: copy
+
+        &.selection
+            #linkeditor-selectlink-overlay
+                display: block
+                cursor: crosshair
+
+                #linkeditor-selectlink-rect
+                    border: 1px dashed #000000
+                    background-color: rgba(255, 255, 255, 0.25)
+                    position: absolute
+
+        #linkeditor-editor
+            position: relative
+            height: calc(100% - $toolbar-height)
+            width: 100%
+            overflow: hidden
+
+            #linkeditor-zoom
+                width: 200%
+                height: 200%
+                max-width: 200%
+                max-height: 200%
+                min-height: 200%
+                min-width: 200%
+                transform-origin: 0 0
+
+            #linkeditor-canvas
+                background-color: #505050
+                @include dark-theme
+                    background-color: #222
+
+                position: relative
+                z-index: 1
+                top: $rulers-size
+                left: $rulers-size
+                height: calc(100% - $rulers-size)
+                max-height: calc(100% - $rulers-size)
+                width: calc(100% - $rulers-size)
+                max-width: calc(100% - $rulers-size)
+                overflow: auto
+
+                @include tinyscrollbars
+
+                &.noscroll
+                    overflow: hidden
+
+                #linkeditor-fluidbook
+                    transform-origin: 0 0
+                    position: absolute
+                    top: 0
+                    left: 0
+
+                    .linkeditor-page
+                        position: absolute
+                        top: 0px
+                        left: 0px
+                        background-color: rgba(255, 255, 255, 0.2)
+                        @include dark-theme
+                            background-color: rgba(0, 0, 0, 0.2)
+
+                        .contents
+                            background-color: #fff
+                            position: absolute
+                            top: 0
+                            left: 0
+                            width: 100%
+                            height: 100%
+
+                            img
+                                display: block
+                                position: absolute
+                                top: 0
+                                left: 0
+                                width: 100%
+                                height: 100%
+                                z-index: 1
+
+                                &.texts
+                                    z-index: 2
+
+                    &.white-overlay
+                        &:after
+                            content: ''
+                            position: absolute
+                            top: 0
+                            left: 0
+                            width: 100%
+                            height: 100%
+                            z-index: 3
+                            pointer-events: none
+                            background-color: rgba(255, 255, 255, 0.75)
+
+    &.single
+        #linkeditor-page-right
+            display: none
+
+#linkeditor-preload, #linkeditor-clipboard
+    display: none
+
+#loader
+    position: absolute
+    top: 0
+    left: 0
+    cursor: wait
+    width: 100%
+    height: 100%
+    z-index: 20000000
+    display: none
+
+@import "inc/_panels"
+@import "inc/_toolbar"
+@import "inc/_rulers"
+@import "inc/_links"
+@import "inc/_form"
+@import "inc/_versions"
+@import "inc/_popup"
+@import "inc/_contextmenu"
+@import "inc/_layers"
+
diff --git a/resources/linkeditor-stable/webpack.mix.js b/resources/linkeditor-stable/webpack.mix.js
new file mode 100644 (file)
index 0000000..cd9aaae
--- /dev/null
@@ -0,0 +1,3 @@
+const mix = require("laravel-mix");
+mix.setPublicPath('public/packages/linkeditor-stable').js('resources/linkeditor-stable/js/linkeditor.js', 'js')
+    .sass('resources/linkeditor/style/style.sass', 'css').options({processCssUrls: false}).version();
index a530bd7ca328d98a30247608522f6bc54460c8c5..60903569b9bc7ad159ee8b3aab2e3e1cf50ac228 100644 (file)
                 'url'=>$crud->route.'/$id/edit/$action',
                 'target'=>'_blank'
                 ];
+      $actions ['links-beta']=
+        [
+            'label'=>__('Modifier les liens').' ('.__('Version de dev').')',
+                'url'=>$crud->route.'/$id/edit/$action',
+                'target'=>'_blank'
+                ];
     }
 @endphp
 @if($entry->allowsEdit())