This commit is contained in:
Maxim Devaev 2022-02-21 04:18:15 +03:00
parent 67839a52a2
commit 96191a1b08
18 changed files with 376 additions and 106 deletions

View File

@ -454,7 +454,8 @@ def _get_config_scheme() -> Dict:
},
"ocr": {
"langs": Option(["eng"], type=valid_string_list, unpack_as="default_langs"),
"langs": Option(["eng"], type=valid_string_list, unpack_as="default_langs"),
"tessdata": Option("/usr/share/tessdata", type=valid_stripped_string_not_empty, unpack_as="data_dir_path")
},
"snapshot": {

View File

@ -63,7 +63,7 @@ class StreamerApi:
)
if snapshot:
if valid_bool(request.query.get("ocr", "false")):
langs = await self.__ocr.get_available_langs()
langs = self.__ocr.get_available_langs()
return Response(
body=(await self.__ocr.recognize(
data=snapshot.data,
@ -107,8 +107,8 @@ class StreamerApi:
default: List[str] = []
available: List[str] = []
if enabled:
default = await self.__ocr.get_default_langs()
available = await self.__ocr.get_available_langs()
default = self.__ocr.get_default_langs()
available = self.__ocr.get_available_langs()
return {
"ocr": {
"enabled": enabled,

View File

@ -32,7 +32,6 @@ from typing import List
from typing import Dict
from typing import Set
from typing import Callable
from typing import Awaitable
from typing import Coroutine
from typing import AsyncGenerator
from typing import Optional
@ -264,16 +263,27 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
await self.__register_ws_client(client)
try:
await self.__send_events_aws(client.ws, [
stage1 = [
("gpio_model_state", self.__user_gpio.get_model()),
("hid_keymaps_state", self.__hid_api.get_keymaps()),
("streamer_ocr_state", self.__streamer_api.get_ocr()),
])
await self.__send_events_aws(client.ws, [
]
stage2 = [
(comp.event_type, comp.get_state())
for comp in self.__components
if comp.get_state
])
]
stages = stage1 + stage2
events = dict(zip(
map(operator.itemgetter(0), stages),
await asyncio.gather(*map(operator.itemgetter(1), stages)),
))
for stage in [stage1, stage2]:
await asyncio.gather(*[
self.__send_event(client.ws, event_type, events.pop(event_type))
for (event_type, _) in stage
])
await self.__send_event(client.ws, "loop", {})
async for msg in client.ws:
@ -391,15 +401,6 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
logger.exception("Cleanup error on %s", comp.name)
logger.info("On-Cleanup complete")
async def __send_events_aws(self, ws: aiohttp.web.WebSocketResponse, sources: List[Tuple[str, Awaitable]]) -> None:
await asyncio.gather(*[
self.__send_event(ws, event_type, state)
for (event_type, state) in zip(
map(operator.itemgetter(0), sources),
await asyncio.gather(*map(operator.itemgetter(1), sources)),
)
])
async def __send_event(self, ws: aiohttp.web.WebSocketResponse, event_type: str, event: Optional[Dict]) -> None:
await ws.send_str(json.dumps({
"event_type": event_type,

View File

@ -20,6 +20,8 @@
# ========================================================================== #
import os
import stat
import io
import ctypes
import ctypes.util
@ -69,7 +71,6 @@ def _load_libtesseract() -> Optional[ctypes.CDLL]:
("TessBaseAPISetImage", None, [POINTER(_TessBaseAPI), c_void_p, c_int, c_int, c_int, c_int]),
("TessBaseAPIGetUTF8Text", POINTER(c_char), [POINTER(_TessBaseAPI)]),
("TessBaseAPISetVariable", c_bool, [POINTER(_TessBaseAPI), c_char_p, c_char_p]),
("TessBaseAPIGetAvailableLanguagesAsVector", POINTER(POINTER(c_char)), [POINTER(_TessBaseAPI)]),
]:
func = getattr(lib, name)
if not func:
@ -86,12 +87,12 @@ _libtess = _load_libtesseract()
@contextlib.contextmanager
def _tess_api(langs: List[str]) -> Generator[_TessBaseAPI, None, None]:
def _tess_api(data_dir_path: str, langs: List[str]) -> Generator[_TessBaseAPI, None, None]:
if not _libtess:
raise OcrError("Tesseract is not available")
api = _libtess.TessBaseAPICreate()
try:
if _libtess.TessBaseAPIInit3(api, None, "+".join(langs).encode()) != 0:
if _libtess.TessBaseAPIInit3(api, data_dir_path.encode(), "+".join(langs).encode()) != 0:
raise OcrError("Can't initialize Tesseract")
if not _libtess.TessBaseAPISetVariable(api, b"debug_file", b"/dev/null"):
raise OcrError("Can't set debug_file=/dev/null")
@ -100,35 +101,32 @@ def _tess_api(langs: List[str]) -> Generator[_TessBaseAPI, None, None]:
_libtess.TessBaseAPIDelete(api)
_LANG_SUFFIX = ".traineddata"
# =====
class TesseractOcr:
def __init__(self, default_langs: List[str]) -> None:
def __init__(self, data_dir_path: str, default_langs: List[str]) -> None:
self.__data_dir_path = data_dir_path
self.__default_langs = default_langs
def is_available(self) -> bool:
return bool(_libtess)
async def get_default_langs(self) -> List[str]:
def get_default_langs(self) -> List[str]:
return list(self.__default_langs)
async def get_available_langs(self) -> List[str]:
return (await aiotools.run_async(self.__inner_get_available_langs))
def __inner_get_available_langs(self) -> List[str]:
with _tess_api(["osd"]) as api:
assert _libtess
langs: Set[str] = set()
langs_ptr = _libtess.TessBaseAPIGetAvailableLanguagesAsVector(api)
if langs_ptr is not None:
index = 0
while langs_ptr[index]:
lang = ctypes.cast(langs_ptr[index], c_char_p).value
if lang is not None:
langs.add(lang.decode())
libc.free(langs_ptr[index])
index += 1
libc.free(langs_ptr)
return sorted(langs)
def get_available_langs(self) -> List[str]:
# Это быстрее чем, инициализация либы и TessBaseAPIGetAvailableLanguagesAsVector()
langs: Set[str] = set()
for lang_name in os.listdir(self.__data_dir_path):
if lang_name.endswith(_LANG_SUFFIX):
path = os.path.join(self.__data_dir_path, lang_name)
if os.access(path, os.R_OK) and stat.S_ISREG(os.stat(path).st_mode):
lang = lang_name[:-len(_LANG_SUFFIX)]
if lang:
langs.add(lang)
return sorted(langs)
async def recognize(self, data: bytes, langs: List[str], left: int, top: int, right: int, bottom: int) -> str:
if not langs:
@ -136,7 +134,7 @@ class TesseractOcr:
return (await aiotools.run_async(self.__inner_recognize, data, langs, left, top, right, bottom))
def __inner_recognize(self, data: bytes, langs: List[str], left: int, top: int, right: int, bottom: int) -> str:
with _tess_api(langs) as api:
with _tess_api(self.__data_dir_path, langs) as api:
assert _libtess
with io.BytesIO(data) as bio:
image = PilImage.open(bio)

View File

@ -506,7 +506,7 @@
</div>
</div>
</li>
<li class="right"><a class="menu-button" href="#">Paste</a>
<li class="right"><a class="menu-button" href="#"><img class="feature-disabled" data-dont-hide-menu id="stream-ocr-led" src="/share/svg/led-gear.svg">Text</a>
<div class="menu" data-dont-hide-menu>
<div class="text"><b>Paste text as keypress sequence<br></b><sub>Please note that PiKVM cannot switch the keyboard layout</sub></div>
<hr>
@ -535,6 +535,35 @@
</td>
</tr>
</table>
<div class="feature-disabled" id="stream-ocr">
<hr><br>
<hr>
<div class="text"><b>Text recognition<br></b><sub><a target="_blank" href="https://docs.pikvm.org/ocr">OCR</a> works locally on PiKVM</sub></div>
<hr>
<table class="kv">
<tr>
<td>
<button data-force-hide-menu id="stream-ocr-button">&bull; Select area</button>
</td>
<td>for</td>
<td>
<select id="stream-ocr-lang-selector"></select>
</td>
<td>text recognition</td>
</tr>
</table>
<table class="kv">
<tr>
<td colspan="4">&bull; Press <b>Enter</b> to recognize and copy text to clipboard</td>
</tr>
<tr>
<td colspan="4">&bull; Press <b>Esc</b> to cancel selection</td>
</tr>
<tr>
<td></td>
</tr>
</table>
</div>
</div>
</li>
<li class="right"><a class="menu-button" href="#">Shortcuts</a>
@ -588,6 +617,9 @@
<div class="menu" data-dont-hide-menu id="gpio-menu"></div>
</li>
</ul>
<div class="window" id="stream-ocr-window">
<div class="hidden" id="stream-ocr-selection"></div>
</div>
<div class="window window-resizable" id="stream-window">
<div class="window-header" id="stream-window-header">
<div class="window-grab">MJPEG</div>

View File

@ -1,17 +0,0 @@
li(class="right")
a(class="menu-button" href="#") Paste
div(data-dont-hide-menu class="menu")
div(class="text")
b Paste text as keypress sequence#[br]
sub Please note that PiKVM cannot switch the keyboard layout
hr
div(class="text")
textarea(id="hid-pak-text" placeholder="Enter your text here")
table(class="kv")
tr
td
button(disabled data-force-hide-menu id="hid-pak-button") &bull; Paste
td using host keymap
td
select(id="hid-pak-keymap-selector")
+menu_switch("hid-pak-ask-switch", "Ask paste confirmation", true, true)

42
web/kvm/navbar-text.pug Normal file
View File

@ -0,0 +1,42 @@
li(class="right")
a(class="menu-button" href="#")
+navbar_led("stream-ocr-led", "led-gear", "feature-disabled")
| Text
div(data-dont-hide-menu class="menu")
div(class="text")
b Paste text as keypress sequence#[br]
sub Please note that PiKVM cannot switch the keyboard layout
hr
div(class="text")
textarea(id="hid-pak-text" placeholder="Enter your text here")
table(class="kv")
tr
td
button(disabled data-force-hide-menu id="hid-pak-button") &bull; Paste
td using host keymap
td
select(id="hid-pak-keymap-selector")
+menu_switch("hid-pak-ask-switch", "Ask paste confirmation", true, true)
div(id="stream-ocr" class="feature-disabled")
hr
br
hr
div(class="text")
b Text recognition#[br]
sub #[a(target="_blank" href="https://docs.pikvm.org/ocr") OCR] works locally on PiKVM
hr
table(class="kv")
tr
td
button(data-force-hide-menu id="stream-ocr-button") &bull; Select area
td for
td
select(id="stream-ocr-lang-selector")
td text recognition
table(class="kv")
tr
td(colspan="4") &bull; Press #[b Enter] to recognize and copy text to clipboard
tr
td(colspan="4") &bull; Press #[b Esc] to cancel selection
tr
td

View File

@ -38,6 +38,6 @@ ul(id="navbar")
include navbar-atx.pug
include navbar-msd.pug
include navbar-macro.pug
include navbar-paste.pug
include navbar-text.pug
include navbar-shortcuts.pug
include navbar-gpio.pug

View File

@ -1,3 +1,6 @@
div(id="stream-ocr-window" class="window")
div(id="stream-ocr-selection" class="hidden")
div(id="stream-window" class="window window-resizable")
div(id="stream-window-header" class="window-header")
div(class="window-grab") MJPEG

View File

@ -23,8 +23,8 @@
textarea#hid-pak-text {
display: block;
resize: none;
height: 150px;
width: 300px;
height: 120px;
width: 320px;
border: var(--border-default-thin);
border-radius: 4px;
color: var(--cs-code-default-fg);

View File

@ -29,6 +29,26 @@ div#stream-info {
display: none;
}
div#stream-ocr-window {
cursor: crosshair;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 1;
background-color: unset !important;
border-radius: unset !important;
border: unset !important;
padding: 0px !important;
background: radial-gradient(transparent 15%, black);
}
div#stream-ocr-selection {
position: relative;
background-color: #5b90bb50;
box-shadow: inset 0 0 0px 1px #e8e8e8cd;
}
div#stream-box {
width: 100%;
height: 100%;

View File

@ -30,7 +30,7 @@ import {Keyboard} from "./keyboard.js";
import {Mouse} from "./mouse.js";
export function Hid(__getResolution, __recorder) {
export function Hid(__getGeometry, __recorder) {
var self = this;
/************************************************************************/
@ -40,7 +40,7 @@ export function Hid(__getResolution, __recorder) {
var __init__ = function() {
__keyboard = new Keyboard(__recorder.recordWsEvent);
__mouse = new Mouse(__getResolution, __recorder.recordWsEvent);
__mouse = new Mouse(__getGeometry, __recorder.recordWsEvent);
let hidden_attr = null;
let visibility_change_attr = null;

View File

@ -27,7 +27,7 @@ import {tools, $} from "../tools.js";
import {Keypad} from "../keypad.js";
export function Mouse(__getResolution, __recordWsEvent) {
export function Mouse(__getGeometry, __recordWsEvent) {
var self = this;
/************************************************************************/
@ -227,10 +227,10 @@ export function Mouse(__getResolution, __recordWsEvent) {
if (__absolute) {
let pos = __current_pos;
if (pos.x !== __sent_pos.x || pos.y !== __sent_pos.y) {
let geo = __getVideoGeometry();
let geo = __getGeometry();
let to = {
"x": __translatePosition(pos.x, geo.x, geo.width, -32768, 32767),
"y": __translatePosition(pos.y, geo.y, geo.height, -32768, 32767),
"x": tools.remap(pos.x, geo.x, geo.width, -32768, 32767),
"y": tools.remap(pos.y, geo.y, geo.height, -32768, 32767),
};
tools.debug("Mouse: moved:", to);
__sendEvent("mouse_move", {"to": to});
@ -243,36 +243,6 @@ export function Mouse(__getResolution, __recordWsEvent) {
}
};
var __getVideoGeometry = function() {
// Первоначально обновление геометрии считалось через ResizeObserver.
// Но оно не ловило некоторые события, например в последовательности:
// - Находять в HD переходим в фулскрин
// - Меняем разрешение на маленькое
// - Убираем фулскрин
// - Переходим в HD
// - Видим нарушение пропорций
// Так что теперь используются быстре рассчеты через offset*
// вместо getBoundingClientRect().
let res = __getResolution();
let ratio = Math.min(res.view_width / res.real_width, res.view_height / res.real_height);
return {
"x": Math.round((res.view_width - ratio * res.real_width) / 2),
"y": Math.round((res.view_height - ratio * res.real_height) / 2),
"width": Math.round(ratio * res.real_width),
"height": Math.round(ratio * res.real_height),
};
};
var __translatePosition = function(x, a, b, c, d) {
let translated = Math.round((x - a) / b * (d - c) + c);
if (translated < c) {
return c;
} else if (translated > d) {
return d;
}
return translated;
};
var __streamWheelHandler = function(event) {
// https://learn.javascript.ru/mousewheel
// https://stackoverflow.com/a/24595588

181
web/share/js/kvm/ocr.js Normal file
View File

@ -0,0 +1,181 @@
/*****************************************************************************
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2022 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
*****************************************************************************/
"use strict";
import {tools, $} from "../tools.js";
import {wm} from "../wm.js";
export function Ocr(__getGeometry) {
var self = this;
/************************************************************************/
var __start_pos = null;
var __end_pos = null;
var __selection = null;
var __init__ = function() {
tools.el.setOnClick($("stream-ocr-button"), function() {
__resetSelection();
wm.showWindow($("stream-window"));
wm.showWindow($("stream-ocr-window"));
});
$("stream-ocr-lang-selector").addEventListener("change", function() {
tools.storage.set("stream.ocr.lang", $("stream-ocr-lang-selector").value);
});
$("stream-ocr-window").addEventListener("blur", __resetSelection);
$("stream-ocr-window").addEventListener("resize", __resetSelection);
$("stream-ocr-window").close_hook = __resetSelection;
$("stream-ocr-window").onkeyup = function(event) {
event.preventDefault();
if (event.code === "Enter") {
__recognizeSelection();
wm.closeWindow($("stream-ocr-window"));
} else if (event.code === "Escape") {
wm.closeWindow($("stream-ocr-window"));
}
};
$("stream-ocr-window").onmousedown = __startSelection;
$("stream-ocr-window").onmousemove = __changeSelection;
$("stream-ocr-window").onmouseup = __endSelection;
};
/************************************************************************/
self.setState = function(state) {
let enabled = (state && state.ocr.enabled && navigator.clipboard && !tools.browser.is_ios);
if (enabled) {
let selected = tools.storage.get("stream.ocr.lang", state.ocr.langs["default"]);
let html = "";
for (let variant of state.ocr.langs.available) {
html += `<option value=${variant} ${variant === selected ? "selected" : ""}>${variant}</option>`;
}
$("stream-ocr-lang-selector").innerHTML = html;
}
tools.feature.setEnabled($("stream-ocr"), enabled);
$("stream-ocr-led").className = (enabled ? "led-gray" : "hidden");
};
var __startSelection = function(event) {
if (__start_pos === null) {
tools.hidden.setVisible($("stream-ocr-selection"), false);
__start_pos = __getGlobalPosition(event);
__end_pos = null;
}
};
var __changeSelection = function(event) {
if (__start_pos !== null) {
__end_pos = __getGlobalPosition(event);
let width = Math.abs(__start_pos.x - __end_pos.x);
let height = Math.abs(__start_pos.y - __end_pos.y);
let el_selection = $("stream-ocr-selection");
el_selection.style.left = Math.min(__start_pos.x, __end_pos.x) + "px";
el_selection.style.top = Math.min(__start_pos.y, __end_pos.y) + "px";
el_selection.style.width = width + "px";
el_selection.style.height = height + "px";
tools.hidden.setVisible(el_selection, (width > 1 || height > 1));
}
};
var __endSelection = function(event) {
__changeSelection(event);
let el_selection = $("stream-ocr-selection");
let ok = (
el_selection.offsetWidth > 1 && el_selection.offsetHeight > 1
&& __start_pos !== null && __end_pos !== null
);
tools.hidden.setVisible(el_selection, ok);
if (ok) {
let rect = $("stream-box").getBoundingClientRect();
let rel_left = Math.min(__start_pos.x, __end_pos.x) - rect.left;
let rel_right = Math.max(__start_pos.x, __end_pos.x) - rect.left;
let rel_top = Math.min(__start_pos.y, __end_pos.y) - rect.top;
let rel_bottom = Math.max(__start_pos.y, __end_pos.y) - rect.top;
let geo = __getGeometry();
__selection = {
left: tools.remap(rel_left, geo.x, geo.width, 0, geo.real_width),
right: tools.remap(rel_right, geo.x, geo.width, 0, geo.real_width),
top: tools.remap(rel_top, geo.y, geo.height, 0, geo.real_height),
bottom: tools.remap(rel_bottom, geo.y, geo.height, 0, geo.real_height),
};
} else {
__selection = null;
}
__start_pos = null;
__end_pos = null;
};
var __getGlobalPosition = function(event) {
let rect = $("stream-box").getBoundingClientRect();
let geo = __getGeometry();
return {
x: Math.min(Math.max(event.clientX, rect.left + geo.x), rect.right - geo.x),
y: Math.min(Math.max(event.clientY, rect.top + geo.y), rect.bottom - geo.y),
};
};
var __resetSelection = function() {
tools.hidden.setVisible($("stream-ocr-selection"), false);
__start_pos = null;
__end_pos = null;
__selection = null;
};
var __recognizeSelection = function() {
tools.el.setEnabled($("stream-ocr-button"), false);
tools.el.setEnabled($("stream-ocr-lang-selector"), false);
$("stream-ocr-led").className = "led-yellow-rotating-fast";
let lang = $("stream-ocr-lang-selector").value;
let url = `/api/streamer/snapshot?ocr=1&ocr_lang=${lang}`;
url += `&ocr_left=${__selection.left}&ocr_top=${__selection.top}`;
url += `&ocr_right=${__selection.right}&ocr_bottom=${__selection.bottom}`;
let http = tools.makeRequest("GET", url, function() {
if (http.readyState === 4) {
if (http.status === 200) {
navigator.clipboard.writeText(http.responseText).then(function() {
wm.info("The text is copied to the clipboard");
}, function() {
wm.error("Can't copy text to the clipboard");
});
} else {
wm.error("OCR error:<br>", http.responseText);
}
tools.el.setEnabled($("stream-ocr-button"), true);
tools.el.setEnabled($("stream-ocr-lang-selector"), true);
$("stream-ocr-led").className = "led-gray";
}
});
};
__init__();
}

View File

@ -32,6 +32,7 @@ import {Atx} from "./atx.js";
import {Msd} from "./msd.js";
import {Streamer} from "./stream.js";
import {Gpio} from "./gpio.js";
import {Ocr} from "./ocr.js";
export function Session() {
@ -46,10 +47,11 @@ export function Session() {
var __streamer = new Streamer();
var __recorder = new Recorder();
var __hid = new Hid(__streamer.getResolution, __recorder);
var __hid = new Hid(__streamer.getGeometry, __recorder);
var __atx = new Atx(__recorder);
var __msd = new Msd();
var __gpio = new Gpio(__recorder);
var __ocr = new Ocr(__streamer.getGeometry);
var __init__ = function() {
__startSession();
@ -251,6 +253,7 @@ export function Session() {
case "atx_state": __atx.setState(data.event); break;
case "msd_state": __msd.setState(data.event); break;
case "streamer_state": __streamer.setState(data.event); break;
case "streamer_ocr_state": __ocr.setState(data.event); break;
}
};
@ -273,6 +276,7 @@ export function Session() {
__ping_timer = null;
}
__ocr.setState(null);
__gpio.setState(null);
__hid.setSocket(null);
__recorder.setSocket(null);

View File

@ -455,8 +455,26 @@ export function Streamer() {
/************************************************************************/
self.getResolution = function() {
return __streamer.getResolution();
self.getGeometry = function() {
// Первоначально обновление геометрии считалось через ResizeObserver.
// Но оно не ловило некоторые события, например в последовательности:
// - Находять в HD переходим в фулскрин
// - Меняем разрешение на маленькое
// - Убираем фулскрин
// - Переходим в HD
// - Видим нарушение пропорций
// Так что теперь используются быстре рассчеты через offset*
// вместо getBoundingClientRect().
let res = __streamer.getResolution();
let ratio = Math.min(res.view_width / res.real_width, res.view_height / res.real_height);
return {
"x": Math.round((res.view_width - ratio * res.real_width) / 2),
"y": Math.round((res.view_height - ratio * res.real_height) / 2),
"width": Math.round(ratio * res.real_width),
"height": Math.round(ratio * res.real_height),
"real_width": res.real_width,
"real_height": res.real_height,
};
};
self.setJanusEnabled = function(enabled) {

View File

@ -83,6 +83,16 @@ export var tools = new function() {
return `${hours}:${mins}:${secs}.${millis}`;
};
self.remap = function(x, a1, b1, a2, b2) {
let remapped = Math.round((x - a1) / b1 * (b2 - a2) + a2);
if (remapped < a2) {
return a2;
} else if (remapped > b2) {
return b2;
}
return remapped;
};
/************************************************************************/
self.el = new function() {

View File

@ -84,10 +84,7 @@ function __WindowManager() {
let el_close_button = el_window.querySelector(".window-header .window-button-close");
if (el_close_button) {
el_close_button.title = "Close window";
tools.el.setOnClick(el_close_button, function() {
__closeWindow(el_window);
__activateLastWindow(el_window);
});
tools.el.setOnClick(el_close_button, () => self.closeWindow(el_window));
}
let el_maximize_button = el_window.querySelector(".window-header .window-button-maximize");
@ -139,6 +136,7 @@ function __WindowManager() {
/************************************************************************/
self.info = (...args) => __modalDialog("Info", args.join(" "), true, false, null);
self.error = (...args) => __modalDialog("Error", args.join(" "), true, false, null);
self.confirm = (...args) => __modalDialog("Question", args.join(" "), true, true, null);
@ -253,6 +251,11 @@ function __WindowManager() {
};
};
self.closeWindow = function(el_window) {
__closeWindow(el_window);
__activateLastWindow(el_window);
};
var __closeWindow = function(el_window) {
el_window.focus();
el_window.blur();
@ -460,6 +463,10 @@ function __WindowManager() {
var __makeWindowMovable = function(el_window) {
let el_header = el_window.querySelector(".window-header");
let el_grab = el_window.querySelector(".window-header .window-grab");
if (el_header === null || el_grab === null) {
// Для псевдоокна OCR
return;
}
let prev_pos = {x: 0, y: 0};