World Library Editor

Visual graph editor for lorebooks in LoreCanvas. Manage nodes, groups, languages, and layers. Stores data in IndexedDB. External script for JanitorAI.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         World Library Editor
// @namespace    Violentmonkey Scripts
// @version      9.0
// @match        https://janitorai.com/*
// @grant        none
// @author       Glazanochi
// @description  Visual graph editor for lorebooks in LoreCanvas. Manage nodes, groups, languages, and layers. Stores data in IndexedDB. External script for JanitorAI.
// ==/UserScript==

/* jshint esversion: 11 */
/* jshint -W083 */

(function() {
    'use strict';

    // ─── Проверка диспетчера и DB ────────────────────────────────────────
    if (!window.__MANAGER__ || !window.__DB__) {
        console.warn('⚠️ Диспетчер или DB не найден. Редактор не может работать.');
        return;
    }

    // ─── Регистрация хранилищ редактора в IndexedDB ──────────────────────
    window.__DB__.registerStore('editor_layers', 'id');
    window.__DB__.registerStore('lorebook_editor', 'key');

    // ─── Локализация ──────────────────────────────────────────────────
    const L10N = {
        currentLang: (window.__L10N__?.currentLang || navigator.language || 'en').startsWith('ru') ? 'ru' : 'en',
        strings: {
            'editor_title': {ru:'Редактор лорбука',en:'Lorebook Editor'},
            'export_btn': {ru:'📤 Экспорт JSON',en:'📤 Export JSON'},
            'import_btn': {ru:'📥 Загрузить лорбук',en:'📥 Upload lorebook'},
            'close_btn': {ru:'✖ Закрыть',en:'✖ Close'},
            'open_editor_btn': {ru:'📝 Открыть редактор',en:'📝 Open editor'},
            'bg_color_label': {ru:'Цвет фона',en:'Background color'},
            'grid_color_label': {ru:'Цвет сетки',en:'Grid color'},
            'dialog_ok': {ru:'OK',en:'OK'},
            'dialog_cancel': {ru:'Отмена',en:'Cancel'},
            'empty_property': {ru:'Выберите узел',en:'Select a node'},
            'no_category': {ru:'— Нет категорий —',en:'— No categories —'},
            'library_lang_label': {ru:'Язык лорбука',en:'Lorebook language'},
            'prop_active': {ru:'Активен',en:'Active'},
            'prop_card_color': {ru:'Цвет узла',en:'Node color'},
            'prop_id': {ru:'ID',en:'ID'},
            'prop_text_color': {ru:'Цвет и Название узла',en:'Color & Label'},
            'prop_label_placeholder': {ru:'Название',en:'Label'},
            'prop_description': {ru:'Описание',en:'Description'},
            'prop_comment': {ru:'Комментарий',en:'Comment'},
            'prop_category': {ru:'Категория',en:'Category'},
            'prop_category_placeholder': {ru:'Введите или выберите категорию',en:'Enter or select category'},
            'prop_alwaysActive': {ru:'Всегда включен',en:'Always active'},
            'prop_caseSensitive': {ru:'Учитывать регистр',en:'Case sensitive'},
            'prop_wholeWords': {ru:'Слова целиком',en:'Whole words'},
            'prop_keywords': {ru:'Основные ключи (через запятую)',en:'Primary keys (comma separated)'},
            'prop_additional_keys': {ru:'Дополнительные ключи (через запятую)',en:'Additional keys (comma separated)'},
            'prop_condition': {ru:'Условие',en:'Condition'},
            'prop_groups': {ru:'Группы (через запятую)',en:'Groups (comma separated)'},
            'prop_weight': {ru:'Вес',en:'Weight'},
            'prop_scan_depth': {ru:'Глубина сканирования',en:'Scan depth'},
            'prop_recursion_depth': {ru:'Глубина рекурсии',en:'Recursion depth'},
            'prop_priority': {ru:'Приоритет',en:'Priority'},
            'prop_chance': {ru:'Шанс',en:'Chance'},
            'prop_insert_position': {ru:'Позиция',en:'Position'},
            'prop_insert_target': {ru:'Цель',en:'Target'},
            'insert_position_start': {ru:'Начало',en:'Start'},
            'insert_position_replace': {ru:'Заменить',en:'Replace'},
            'insert_position_end': {ru:'Конец',en:'End'},
            'insert_target_llm': {ru:'Пользовательский промпт',en:'Custom prompt'},
            'insert_target_summary': {ru:'Резюме чата (summary)',en:'Chat summary'},
            'insert_target_user_appearance': {ru:'Персона',en:'Persona'},
            'insert_target_prefill': {ru:'Предзаполнение',en:'Prefill'},
            'insert_target_last_message': {ru: 'Последнее сообщение пользователя',en: 'The user"s last message'},
            'condition_and_any': {ru:'И ЛЮБОЙ',en:'AND ANY'},
            'condition_and_all': {ru:'И ВСЕ',en:'AND ALL'},
            'condition_not_all': {ru:'НЕ ВСЕ',en:'NOT ALL'},
            'condition_not_any': {ru:'НЕ ЛЮБОЙ',en:'NOT ANY'},
            'prop_ooc_instruction': {ru: 'Инструкция ООС',en: 'OOC Instruction'},
            'tooltip_ooc_instruction': {
                ru: 'Если активно, и если цель - Последнее сообщение, то инъекция в последнее сообщение пользователя будет вставлена как OOC-инструкция: (OOC: ...инъекция...)',
                en: 'If active, and if the target is Last Message, then the injection into the user"s last message will be inserted as an OOC instruction: (OOC: ...injection...)'
            },
            'tooltip_active': {
                ru: 'Определяет, участвует ли узел в обработке. Отключение временно «замораживает» запись, не удаляя её, — полезно при отладке или сезонных настройках.',
                en: 'Determines whether the node participates in processing. Disabling temporarily "freezes" the entry without deleting it — useful for debugging or seasonal adjustments.'
            },
            'tooltip_card_color': {
                ru: 'Задаёт цвет фона карточки на холсте. Помогает быстро визуально группировать записи (например, персонажи — синим, локации — зелёным).',
                en: 'Sets the background color of the node card on the canvas. Helps quickly visually group entries (e.g., characters in blue, locations in green).'
            },
            'tooltip_text_color': {
                ru: 'Определяет цвет текста заголовка. Используется для дополнительной маркировки важных узлов прямо на холсте.',
                en: 'Sets the title text color. Used for additional marking of important nodes directly on the canvas.'
            },
            'tooltip_description': {
                ru: 'Основной содержательный текст узла (лор, описание персонажа, места, события). Именно этот текст ИИ встраивает в контекст при активации узла.',
                en: 'The main content text of the node (lore, character description, location, event). This is the text the AI inserts into context when the node is activated.'
            },
            'tooltip_comment': {
                ru: 'Ваша личная заметка, не передаётся ИИ. Служит для внутренних пометок, идей или напоминаний.',
                en: 'Your personal note, not passed to the AI. Used for internal notes, ideas, or reminders.'
            },
            'tooltip_category': {
                ru: 'Тип сущности (например, Персонаж, Локация, Артефакт). Влияет на логику работы некоторых модулей и помогает в фильтрации.',
                en: 'Entity type (e.g., Character, Location, Artifact). Affects the logic of some modules and helps with filtering.'
            },
            'tooltip_alwaysActive': {
                ru: 'Если активно, узел всегда присутствует в контексте, игнорируя ключевые слова. Используйте для основ мира (законы магии, глобальные конфликты).',
                en: 'If active, the node is always present in the context, ignoring keywords. Use for world foundations (laws of magic, global conflicts).'
            },
            'tooltip_caseSensitive': {
                ru: 'Включает чувствительность к регистру при поиске ключей. «Король» и «король» будут разными триггерами — для точной настройки.',
                en: 'Enables case sensitivity when matching keywords. "King" and "king" will be different triggers — for fine-tuning.'
            },
            'tooltip_wholeWords': {
                ru: 'Требует, чтобы ключевое слово было отдельным словом, а не частью другого. Например, триггер «кот» не сработает на «котлета». Это предотвращает ложные активации.',
                en: 'Requires the keyword to be a whole word, not part of another. For example, "cat" won\'t trigger on "caterpillar". Prevents false activations.'
            },
            'tooltip_keywords': {
                ru: 'Главные слова/фразы, запускающие узел. Поддерживаются регулярные выражения и маски (например, маск* для поиска всех слов, начинающихся с «маск»).',
                en: 'Primary words/phrases that trigger the node. Supports regular expressions and wildcards (e.g., mask* to match all words starting with "mask").'
            },
            'tooltip_additional_keys': {
                ru: 'Второстепенные ключи, которые активируют узел только в связке с основными (по правилу, заданному в поле «Условие»). Также поддерживают regex и маски.',
                en: 'Secondary keys that activate the node only in combination with primary keys (according to the "Condition" rule). Also support regex and wildcards.'
            },
            'tooltip_condition': {
                ru: 'Логика комбинирования основных и дополнительных ключей: И ЛЮБОЙ — достаточно совпадения хотя бы одного основного и хотя бы одного дополнительного ключа. И ВСЕ — должны совпасть все основные и все дополнительные ключи. НЕ ВСЕ — активация, если не совпадают все ключи (частичное совпадение допустимо). НЕ ЛЮБОЙ — активация, если ни один из ключей не совпал (отрицание).',
                en: 'Logic for combining primary and additional keys: AND ANY — at least one primary and one additional key must match. AND ALL — all primary and all additional keys must match. NOT ALL — activation if not all keys match (partial match allowed). NOT ANY — activation if none of the keys match (negation).'
            },
            'tooltip_groups': {
                ru: 'Теги для объединения узлов (например, «Погода», «Магия»). Важно: в одном контекстном блоке может оказаться не более одной записи из каждой группы. Если узел принадлежит нескольким группам, это ограничение действует для каждой из них. Перечисляются через запятую.',
                en: 'Tags for grouping nodes (e.g., "Weather", "Magic"). Important: no more than one entry from each group can appear in a single context block. If a node belongs to multiple groups, this restriction applies to each group. Listed separated by commas.'
            },
            'tooltip_weight': {
                ru: 'Числовой вес (по умолчанию 100). Если в одной группе активировалось несколько узлов, система случайным образом выбирает один из них, учитывая веса (чем больше вес, тем выше шанс). Влияет только на отбор внутри группы.',
                en: 'Numeric weight (default 100). If multiple nodes in the same group are activated, the system randomly selects one, considering weights (higher weight = higher chance). Affects selection only within a group.'
            },
            'tooltip_scan_depth': {
                ru: 'Ограничивает поиск ключевых слов последними N сообщениями (диалоговыми оборотами) в контексте. 0 — поиск по всей доступной истории (крайне не рекомендуется). Помогает снизить нагрузку и фокусироваться на свежих репликах.',
                en: 'Limits keyword search to the last N messages (dialog turns) in the context. 0 — search the entire available history (highly discouraged). Helps reduce load and focus on recent replies.'
            },
            'tooltip_recursion_depth': {
                ru: 'Если активированный узел содержит в своём тексте ключи других узлов, система может рекурсивно активировать и их. Этот параметр задаёт максимальную глубину цепочки (сколько раз активация может «перепрыгивать» с узла на узел). 0 — рекурсия отключена (активируется только исходный узел).',
                en: 'If an activated node contains keys of other nodes in its text, the system can recursively activate them too. This sets the maximum chain depth (how many times activation can "jump" from node to node). 0 — recursion disabled (only the initial node is activated).'
            },
            'tooltip_priority': {
                ru: 'Приоритет при конфликте между узлами из разных групп (или без групп). Чем выше число, тем выше приоритет. Если несколько узлов претендуют на одно место в контексте, побеждает узел с наибольшим приоритетом.',
                en: 'Priority in conflicts between nodes from different groups (or ungrouped). Higher number = higher priority. If multiple nodes compete for a single context slot, the node with the highest priority wins.'
            },
            'tooltip_chance': {
                ru: 'Вероятность активации узла.\n• 0–99% — узел проходит проверку при первичном отборе. Если не прошёл, он не участвует в групповом отборе.\n• 100% — узел гарантированно участвует в первичном и групповом отборе и будет вставлен (если не отсеется лимитом).\n• 101–199% — узел гарантированно участвует в первичном и групповом отборе, но перед вставкой проходит вторую проверку с вероятностью (значение − 100)%. Например, 150% = 50% на финальную вставку.\n200% не используется, так как эквивалентно 100%.',
                en: 'Node activation probability.\n• 0–99% — node passes a chance check at the primary selection stage. If it fails, it won’t take part in group selection.\n• 100% — node is guaranteed to participate in primary and group selection and will be inserted (unless cut by the limit).\n• 101–199% — node is guaranteed to participate in primary and group selection, but before insertion it undergoes a second chance check with probability (value − 100)%. For example, 150% = 50% chance to be inserted.\n200% is not used, as it is equivalent to 100%.'
            },
            'tooltip_insert_target': {
                ru: 'Выбирает для инъекции конкретный слот в контекстном окне.\n⚠️ В настоящее время prefill(предзаполнение) работает нестабильно с Janitor LLM. Лучше не используйте его.',
                en: 'Selects a specific slot for injection in the context window.\n⚠️ Prefill is currently unstable with Janitor LLM. It"s best not to use it.'
            },
            'tooltip_insert_position': {
                ru: 'Определяет способ вставки содержимого узла внутри выбранного слота: перед существующим текстом, после него или вместо него. Режим перезаписи крайне не рекомендуется, используйте только если четко понимаете, что делаете.',
                en: 'Determines how the node content is inserted within the selected slot: before the existing text, after it, or instead of it. Overwrite mode is highly discouraged; use only if you clearly understand what you are doing.'
            },
            'tooltip_image': {
                ru: 'Изображение узла. Поддерживаются эмодзи (например, 🌟) и вставка Base64-кодированных картинок (максимум 100000 символов). Для вставки картинки используйте кнопку «Вставить Base64» и вставьте строку, начинающуюся с data:image/.',
                en: 'Node image. Supports emoji (e.g., 🌟) and Base64-encoded images (100000 characters). To insert an image, use the "Insert Base64" button and paste a string starting with data:image/.'
            },
            'tooltip_lang_manager': {
                ru: 'Управление языками. Здесь можно добавлять, удалять и переключать языки для заполнения полей узла (название, описание, комментарий, категория). Текст на разных языках хранится в одном узле.',
                en: 'Language management. Add, remove, and switch languages for node fields (title, description, comment, category). Text in different languages is stored in the same node.'
            },
            'lang_manage_title': {ru:'Управление языками',en:'Manage languages'},
            'lang_add': {ru:'Добавить язык',en:'Add language'},
            'lang_delete': {ru:'Удалить',en:'Delete'},
            'lang_delete_confirm': {ru:'ВНИМАНИЕ! Удаление языка удалит все поля для этого языка во всех узлах. Продолжить?',en:'WARNING! Deleting a language will remove all fields for this language in all nodes. Continue?'},
            'lang_no_empty': {ru:'Выберите язык из списка.',en:'Select a language from the list.'},
            'lang_already_exists': {ru:'Этот язык уже добавлен.',en:'This language is already added.'},
            'lang_cant_delete_last': {ru:'Нельзя удалить последний язык.',en:'Cannot delete the last language.'},
            'delete_node_btn': {ru:'🗑️ Удалить узел',en:'🗑️ Delete node'},
            'outgoing_title': {ru:'Исходящие (потомки):',en:'Outgoing (children):'},
            'incoming_title': {ru:'Входящие (предки):',en:'Incoming (parents):'},
            'btn_insert_base64': {ru:'Вставить Base64',en:'Insert Base64'},
            'btn_choose_emoji': {ru:'Выбрать эмодзи',en:'Choose emoji'},
            'btn_clear_image': {ru:'Очистить',en:'Clear'},
            'image_placeholder': {ru:'📷',en:'📷'},
            'dialog_confirm_delete_node': {ru:'Удалить узел и все связанные с ним связи?',en:'Delete node and all its edges?'},
            'dialog_confirm_delete_edge': {ru:'Удалить все связи между этими узлами?',en:'Delete all edges between these nodes?'},
            'dialog_insert_base64_title': {ru:'Вставить Base64',en:'Insert Base64'},
            'dialog_insert_base64_msg': {ru:'Вставьте строку Base64 (начинается с data:image/):',en:'Paste Base64 string (starts with data:image/):'},
            'dialog_insert_base64_placeholder': {ru:'data:image/png;base64,...',en:'data:image/png;base64,...'},
            'dialog_base64_error_title': {ru:'Ошибка',en:'Error'},
            'dialog_base64_error_msg': {ru:'Строка должна начинаться с "data:image/".',en:'String must start with "data:image/".'},
            'dialog_base64_size_error': {ru:'Размер превышает 100000 символов.',en:'Size exceeds 100000 characters.'},
            'dialog_clear_image_info': {ru:'У узла уже установлено изображение по умолчанию.',en:'Node already has default image.'},
            'dialog_clear_image_confirm': {ru:'Очистить изображение узла?',en:'Clear node image?'},
            'dialog_import_title': {ru:'Импорт',en:'Import'},
            'dialog_import_msg': {ru:'Данные импортированы.',en:'Data imported.'},
            'dialog_import_error': {ru:'Неверный формат данных.',en:'Invalid data format.'},
            'dialog_import_parse_error': {ru:'Ошибка при разборе JSON.',en:'Error parsing JSON.'},
            'dialog_node_name_prompt': {ru:'Введите название узла (можно оставить пустым):',en:'Enter node name (can be empty):'},
            'dialog_node_name_placeholder': {ru:'Название...',en:'Name...'},
            'color_picker_title_text': {ru:'Цвет текста',en:'Text color'},
            'color_picker_title_card': {ru:'Цвет узла',en:'Node color'},
            'color_picker_title_background': {ru:'Цвет фона',en:'Background color'},
            'color_picker_title_grid': {ru:'Цвет сетки',en:'Grid color'},
            'color_picker_ok': {ru:'OK',en:'OK'},
            'color_picker_cancel': {ru:'Отмена',en:'Cancel'},
            'color_picker_error_title': {ru:'Ошибка',en:'Error'},
            'color_picker_error_msg': {ru:'Введите корректный HEX-код (например, #ff0000 или ff0000).',en:'Enter a valid HEX code (e.g., #ff0000 or #ff0000).'},
            'emoji_picker_title': {ru:'Выберите эмодзи',en:'Choose emoji'},
            'emoji_custom_label': {ru:'Свой:',en:'Custom:'},
            'emoji_custom_ok': {ru:'OK',en:'OK'},
            'emoji_custom_cancel': {ru:'Отмена',en:'Cancel'},
            'emoji_custom_error': {ru:'Введите эмодзи.',en:'Enter an emoji.'},
            'emoji_cat_emotions': {ru:'Эмоции и лица',en:'Emotions & Faces'},
            'emoji_cat_people': {ru:'Люди, жесты, профессии',en:'People, Gestures, Professions'},
            'emoji_cat_animals': {ru:'Животные, растения, погода',en:'Animals, Plants, Weather'},
            'emoji_cat_food': {ru:'Еда, напитки',en:'Food & Drinks'},
            'emoji_cat_travel': {ru:'Путешествия, места',en:'Travel & Places'},
            'emoji_cat_sports': {ru:'Мероприятия, спорт, искусство',en:'Events, Sports, Arts'},
            'emoji_cat_objects': {ru:'Предметы, вещи, техника',en:'Objects, Things, Tech'},
            'emoji_cat_symbols': {ru:'Символы',en:'Symbols'},
            'context_create_edge': {ru:'➕ Создать связь',en:'➕ Create edge'},
            'context_delete_edge': {ru:'🗑️ Удалить связь',en:'🗑️ Delete edge'},
            'context_create_node': {ru:'➕ Создать узел',en:'➕ Create node'},
            'tip_select_target': {ru:'Выберите целевой узел',en:'Select target node'},
            'layer_create': {ru:'➕',en:'➕'},
            'layer_delete': {ru:'✖',en:'✖'},
            'layer_delete_confirm': {ru:'Удалить слой "{name}"? Все данные слоя будут безвозвратно удалены.',en:'Delete layer "{name}"? All layer data will be permanently deleted.'},
            'layer_renamed': {ru:'Переименовать слой(лорбук)',en:'Rename layer(Lorebook)'},
            'layer_name_prompt': {ru:'Введите новое имя для слоя:',en:'Enter new layer name:'},
            'layer_name_exists': {ru:'Слой с таким именем уже существует.',en:'A layer with this name already exists.'},
            'layer_name_empty': {ru:'Имя не может быть пустым.',en:'Name cannot be empty.'},
            'layer_conflict_title': {ru:'Конфликт имён',en:'Name conflict'},
            'layer_conflict_msg': {ru:'Слой с именем "{name}" уже существует. Действие:',en:'A layer with name "{name}" already exists. Action:'},
            'layer_conflict_overwrite': {ru:'Перезаписать',en:'Overwrite'},
            'layer_conflict_rename': {ru:'Переименовать',en:'Rename'},
            'layer_rename_btn': {ru:'✏️',en:'✏️'},
            'lorebook_manager_title': {ru:'📚 Менеджер лорбуков',en:'📚 Lorebook Manager'},
            'lorebook_enabled': {ru:'Включен',en:'Enabled'},
            'lorebook_language': {ru:'Язык',en:'Language'},
            'lorebook_delete': {ru:'Удалить',en:'Delete'},
            'lorebook_empty': {ru:'Нет лорбуков',en:'No lorebooks'},
            'no_active_layer': {ru:'Нет активного слоя. Создайте слой.',en:'No active layer. Create a layer.'},
            'lorebook_manager_msg': {
                ru: 'Управляйте своими лорбуками. Включайте только те лорбуки, которые необходимы модулям(скриптам) вашего чата . Для каждого лорбука можно выбрать язык, если его автор создал описания на нескольких языках. Меняйте порядок активных лорбуков - движок лорбуков в первую очередь обрабатывает верхние позиции. Удаление лорбука безвозвратно. Кнопка «Загрузить лорбук» позволяет импортировать готовый JSON-файл с лорбуком.',
                en: 'Manage your lorebooks. Enable only those lorebooks that are needed for your chat\'s modules (scripts). For each lorebook, you can select a language if the author has provided descriptions in multiple languages. Change the order of active lorebooks - the lorebook engine processes the top positions first. Deleting a lorebook is permanent. The "Upload lorebook" button lets you import a ready-made JSON file with a lorebook.'
            },
            'lorebook_active_title': {ru:'Активные лорбуки',en:'Active lorebooks'},
            'lorebook_inactive_title': {ru:'Все лорбуки',en:'All lorebooks'},
            'lorebook_no_active': {ru:'Нет активных лорбуков',en:'No active lorebooks'},
            'lorebook_all_active': {ru:'Все лорбуки активированы',en:'All lorebooks are active'},
            'move_up': {ru:'Переместить вверх',en:'Move up'},
            'move_down': {ru:'Переместить вниз',en:'Move down'},
            'remove_from_active': {ru:'Убрать из активных',en:'Remove from active'},
            'add_to_active': {ru:'Добавить в активные',en:'Add to active'},
            'dialog_confirm_delete_title': {ru:'Удалить лорбук?',en:'Delete lorebook?'},
            'dialog_confirm_delete_msg': {ru:'Вы уверены, что хотите удалить "{name}"?',en:'Are you sure you want to delete "{name}"?'},
        },
        t(key, vars) {
            const lang = this.currentLang;
            let str = this.strings[key]?.[lang] || this.strings[key]?.en || key;
            if (vars) str = str.replace(/{([^}]+)}/g, (_, p) => vars[p] !== undefined ? vars[p] : '');
            return str;
        },
        setLang(lang) {
            if (lang === 'ru' || lang === 'en') {
                this.currentLang = lang;
                if (window.__L10N__) window.__L10N__.currentLang = lang;
            }
        }
    };

    // ─── Список языков ISO 639-1 с родными названиями ────
    const ISO_LANGUAGES_NATIVE = {
        aa: 'Afaraf', ab: 'аҧсуа бызшәа', ae: 'avesta', af: 'Afrikaans', ak: 'Akan',
        am: 'አማርኛ', an: 'aragonés', ar: 'العربية', as: 'অসমীয়া', av: 'авар мацӀ',
        ay: 'aymar aru', az: 'azərbaycan dili', ba: 'башҡорт теле', be: 'беларуская мова',
        bg: 'български език', bh: 'भोजपुरी', bi: 'Bislama', bm: 'bamanankan', bn: 'বাংলা',
        bo: 'བོད་ཡིག', br: 'brezhoneg', bs: 'bosanski jezik', ca: 'Català', ce: 'нохчийн мотт',
        ch: 'Chamoru', co: 'corsu', cr: 'ᓀᐦᐃᔭᐍᐏᐣ', cs: 'čeština', cu: 'ѩзыкъ словѣньскъ',
        cv: 'чӑваш чӗлхи', cy: 'Cymraeg', da: 'dansk', de: 'Deutsch', dv: 'ދިވެހި',
        dz: 'རྫོང་ཁ', ee: 'Eʋegbe', el: 'Ελληνικά', en: 'English', eo: 'Esperanto',
        es: 'Español', et: 'eesti', eu: 'euskara', fa: 'فارسی', ff: 'Fulfulde',
        fi: 'suomi', fj: 'Vakaviti', fo: 'føroyskt', fr: 'Français', fy: 'Frysk',
        ga: 'Gaeilge', gd: 'Gàidhlig', gl: 'galego', gn: "Avañe'ẽ", gu: 'ગુજરાતી',
        gv: 'Gaelg', ha: 'هَوُسَ', he: 'עברית', hi: 'हिन्दी', ho: 'Hiri Motu',
        hr: 'Hrvatski', ht: 'Kreyòl ayisyen', hu: 'magyar', hy: 'Հայերեն', hz: 'Otjiherero',
        ia: 'Interlingua', id: 'Bahasa Indonesia', ie: 'Interlingue', ig: 'Asụsụ Igbo',
        ii: 'ꆈꌠ꒿ Nuosuhxop', ik: 'Iñupiaq', io: 'Ido', is: 'Íslenska', it: 'Italiano',
        iu: 'ᐃᓄᒃᑎᑐᑦ', ja: '日本語', jv: 'basa Jawa', ka: 'ქართული', kg: 'Kikongo',
        ki: 'Gĩkũyũ', kj: 'Kuanyama', kk: 'қазақ тілі', kl: 'kalaallisut', km: 'ខេមរភាសា',
        kn: 'ಕನ್ನಡ', ko: '한국어', kr: 'Kanuri', ks: 'कश्मीरी', ku: 'Kurdî', kv: 'коми кыв',
        kw: 'Kernewek', ky: 'Кыргызча', la: 'latine', lb: 'Lëtzebuergesch', lg: 'Luganda',
        li: 'Limburgs', ln: 'Lingála', lo: 'ພາສາລາວ', lt: 'lietuvių kalba', lu: 'Tshiluba',
        lv: 'latviešu valoda', mg: 'fiteny malagasy', mh: 'Kajin M̧ajeļ', mi: 'te reo Māori',
        mk: 'македонски јазик', ml: 'മലയാളം', mn: 'Монгол хэл', mr: 'मराठी', ms: 'Bahasa Melayu',
        mt: 'Malti', my: 'ဗမာစာ', na: 'Ekakairũ Naoero', nb: 'Norsk bokmål', nd: 'isiNdebele',
        ne: 'नेपाली', ng: 'Owambo', nl: 'Nederlands', nn: 'Norsk nynorsk', no: 'Norsk',
        nr: 'isiNdebele', nv: 'Diné bizaad', ny: 'chiCheŵa', oc: 'occitan', oj: 'ᐊᓂᔑᓈᐯᒧᐎᓐ',
        om: 'Afaan Oromoo', or: 'ଓଡ଼ିଆ', os: 'ирон æвзаг', pa: 'ਪੰਜਾਬੀ', pi: 'पाऴि',
        pl: 'Polski', ps: 'پښتو', pt: 'Português', qu: 'Runa Simi', rm: 'rumantsch grischun',
        rn: 'Ikirundi', ro: 'Română', ru: 'Русский', rw: 'Ikinyarwanda', sa: 'संस्कृतम्',
        sc: 'sardu', sd: 'सिन्धी', se: 'Davvisámegiella', sg: 'yângâ tî sängö', si: 'සිංහල',
        sk: 'slovenčina', sl: 'slovenščina', sm: 'Gagana Sāmoa', sn: 'chiShona', so: 'Soomaaliga',
        sq: 'Shqip', sr: 'српски језик', ss: 'SiSwati', st: 'Sesotho', su: 'Basa Sunda',
        sv: 'Svenska', sw: 'Kiswahili', ta: 'தமிழ்', te: 'తెలుగు', tg: 'тоҷикӣ',
        th: 'ไทย', ti: 'ትግርኛ', tk: 'Türkmen', tl: 'Wikang Tagalog', tn: 'Setswana',
        to: 'faka Tonga', tr: 'Türkçe', ts: 'Xitsonga', tt: 'татар теле', tw: 'Twi',
        ty: 'Reo Tahiti', ug: 'ئۇيغۇرچە', uk: 'Українська', ur: 'اردو', uz: 'Oʻzbek',
        ve: 'Tshivenḓa', vi: 'Tiếng Việt', vo: 'Volapük', wa: 'walon', wo: 'Wollof',
        xh: 'isiXhosa', yi: 'ייִדיש', yo: 'Yorùbá', za: 'Saɯ cueŋƅ', zh: '中文', zu: 'isiZulu'
    };

    // ─── ГЛОБАЛЬНЫЕ СТИЛИ (адаптированы под мобильные) ─────────────
    (function addGlobalStyles() {
        const style = document.createElement('style');
        style.textContent = `
            /* базовые стили */
            .lore-btn{background:#313244;border:1px solid #45475a;border-radius:4px;color:#cdd6f4;cursor:pointer;padding:6px 12px;font-weight:bold;transition:0.15s;}
            .lore-btn:hover{background:#45475a;border-color:#89b4fa;}
            .lore-btn-primary{background:#89b4fa;color:#111;}.lore-btn-primary:hover{background:#6a9bd6;}
            .lore-btn-success{background:#a6e3a1;color:#111;}.lore-btn-success:hover{background:#8ccf89;}
            .lore-btn-danger{background:#f38ba8;color:#111;}.lore-btn-danger:hover{background:#e06c8a;}
            .lore-btn-warning{background:#f9e2af;color:#111;}.lore-btn-warning:hover{background:#e8c98a;}
            .lore-btn-ghost{background:#313244;border:1px solid #45475a;color:#cdd6f4;}.lore-btn-ghost:hover{background:#45475a;}
            .lore-btn-sm{padding:4px 8px;font-size:12px;}
            .lore-toolbar-btn{background:#313244;border:1px solid #45475a;border-radius:4px;color:#cdd6f4;cursor:pointer;padding:4px 8px;font-weight:bold;transition:0.15s;display:inline-flex;align-items:center;gap:4px;font-size:13px;}
            .lore-toolbar-btn:hover{background:#45475a;border-color:#89b4fa;}
            .lore-color-swatch{display:inline-block;width:30px;height:30px;border-radius:3px;border:1px solid #45475a;}
            .lore-input{background:#11111b;border:1px solid #45475a;color:#cdd6f4;border-radius:4px;padding:4px 6px;box-sizing:border-box;width:100%;}
            .lore-textarea{background:#11111b;border:1px solid #45475a;color:#cdd6f4;border-radius:4px;padding:4px 6px;box-sizing:border-box;width:100%;resize:vertical;}
            .lore-label{font-weight:bold;display:flex;align-items:center;gap:0;}
            .lore-panel-title{margin:0 0 8px 0;color:#cba6f7;}
            .lore-flex-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
            .lore-flex-between{display:flex;justify-content:space-between;align-items:center;}
            .lore-flex-wrap{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
            .lore-property-group{display:flex;flex-direction:column;gap:2px;margin-top:4px;}
            .lore-card-color-btn{width:32px;height:32px;border-radius:4px;border:1px solid #45475a;cursor:pointer;padding:0;flex-shrink:0;}
            .lore-lang-btn{background:#313244;color:#cdd6f4;border:1px solid #45475a;border-radius:4px;padding:2px 8px;font-size:12px;cursor:pointer;}
            .lore-context-item{padding:8px 16px;cursor:pointer;color:#cdd6f4;transition:0.15s;}.lore-context-item:hover{background:#313244;}
            .lore-context-item-danger{color:#f38ba8;}
            .lore-checkbox{width:20px;height:20px;margin:0;cursor:pointer;}
            .lore-emoji-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:0;justify-items:stretch;}
            .lore-emoji-item{font-size:30px;cursor:pointer;padding:2px 0;border-radius:4px;transition:0.1s;display:flex;align-items:center;justify-content:center;aspect-ratio:1;width:100%;box-sizing:border-box;overflow:hidden;white-space:nowrap;min-width:0;}
            .lore-emoji-item:hover{background:#313244;}
            .lore-emoji-header{grid-column:1/-1;font-size:13px;font-weight:bold;color:#cba6f7;padding:6px 0 2px 0;border-bottom:1px solid #45475a;text-align:left;margin:6px 0 2px 0;}
            .lore-color-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:4px;justify-items:stretch;max-height:300px;overflow-y:auto;}
            .lore-color-swatch-small{width:100%;aspect-ratio:1;border-radius:4px;border:1px solid #45475a;cursor:pointer;transition:0.1s;}
            .lore-color-swatch-small:hover{border-color:#f9e2af;}
            /* адаптивные модальные окна */
            .lore-modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);z-index:10000008;display:flex;justify-content:center;align-items:center;}
            .lore-modal-box{background:#1e1e2e;padding:10px;border-radius:16px;border:2px solid #cba6f7;max-width:400px;width:95%;max-height:95vh;color:#cdd6f4;box-shadow:0 8px 32px rgba(0,0,0,0.9);}
            .lore-modal-title{margin:0 0 12px 0;text-align:center;color:#cba6f7;}
            .lore-modal-actions{display:flex;justify-content:center;gap:12px;margin-top:8px;}
            .lore-list-item{display:flex;justify-content:space-between;align-items:center;padding:6px 8px;border-bottom:1px solid #313244;cursor:pointer;}
            .lore-list-item-active{background:#313244;}
            .lore-list-name{flex:1;}
            .lore-emoji-custom{border-top:1px solid #45475a;padding-top:8px;display:flex;gap:6px;align-items:center;flex-shrink:0;}
            .lore-emoji-custom-label{font-size:13px;white-space:nowrap;}
            .lore-emoji-custom-input{flex:1;min-width:0;background:#11111b;border:1px solid #45475a;color:#fff;border-radius:4px;padding:4px 6px;font-size:20px;text-align:center;width:80px;}
            .lore-image-preview-container{width:96px;height:96px;border:2px solid #45475a;border-radius:6px;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#11111b;}
            .lore-image-preview{font-size:48px;line-height:1;display:flex;align-items:center;justify-content:center;width:100%;height:100%;}
            .tooltip-icon{cursor:help;font-size:14px;color:#89b4fa;margin-left:1px;}
            .tooltip-box{display:none;position:fixed;background:#1e1e2e;border:1px solid #45475a;border-radius:6px;padding:8px 12px;max-width:300px;color:#cdd6f4;font-size:14px;line-height:1.5;z-index:10000020;box-shadow:0 4px 12px rgba(0,0,0,0.6);pointer-events:none;white-space:pre-line;}
            #prop-insertTarget { width: 200px; }
            .lore-layer-select { background:#11111b; color:#cdd6f4; border:1px solid #45475a; border-radius:4px; padding:4px 8px; font-size:14px; cursor:pointer; margin-right:6px; }
            .lore-layer-btn { background:#313244; border:1px solid #45475a; border-radius:4px; color:#cdd6f4; cursor:pointer; padding:4px 8px; font-size:14px; transition:0.15s; }
            .lore-layer-btn:hover { background:#45475a; border-color:#89b4fa; }
            .lore-layer-btn-danger { color:#f38ba8; }
            .lore-layer-btn-danger:hover { background:#f38ba8; color:#111; }
            /* скрываем разделитель на узких экранах */
            .toolbar-separator { display:inline; }
            @media (max-width: 400px) {
                .toolbar-separator { display:none; }
            }
            /* отключаем жесты браузера на холсте */
            canvas#graph-canvas { touch-action: none; }
        `;
        document.head.appendChild(style);
    })();

    // ─── Константы и утилиты ──────────────────────────────────────────
    const MAX_IMAGE_SIZE = 100000;

    // Функции работы с данными через универсальное API __DB__
    async function getLayerList() {
        return await window.__DB__.dbGetAllKeys('editor_layers');
    }

    async function loadLayer(layerId) {
        const store = await window.__DB__.getStore('editor_layers');
        return new Promise((resolve, reject) => {
            const req = store.get(layerId);
            req.onsuccess = () => resolve(req.result || null);
            req.onerror = () => reject(req.error);
        });
    }

    async function saveLayer(layerId, data) {
        const store = await window.__DB__.getStore('editor_layers', 'readwrite');
        return new Promise((resolve, reject) => {
            const record = { id: layerId, ...data };
            const req = store.put(record);
            req.onsuccess = () => resolve();
            req.onerror = () => reject(req.error);
        });
    }

    async function deleteLayer(layerId) {
        await window.__DB__.dbDelete('editor_layers', layerId);
    }

    async function getCurrentLayerId() {
        return await window.__DB__.dbGet('lorebook_editor', 'current_layer');
    }

    async function setCurrentLayerId(layerId) {
        await window.__DB__.dbSet('lorebook_editor', 'current_layer', layerId);
    }

    async function loadBgColor() {
        return await window.__DB__.dbGet('lorebook_editor', 'bg_color') || '#11111b';
    }

    async function saveBgColor(c) {
        await window.__DB__.dbSet('lorebook_editor', 'bg_color', c);
    }

    async function loadGridColor() {
        return await window.__DB__.dbGet('lorebook_editor', 'grid_color') || '#45475a';
    }

    async function saveGridColor(c) {
        await window.__DB__.dbSet('lorebook_editor', 'grid_color', c);
    }

    async function loadPanelWidth() {
        const w = await window.__DB__.dbGet('lorebook_editor', 'panel_width');
        return Math.min(800, Math.max(300, parseInt(w) || 300));
    }

    async function savePanelWidth(w) {
        await window.__DB__.dbSet('lorebook_editor', 'panel_width', String(w));
    }

    // Порядок лорбуков
    async function moveLayerUp(layerId) {
        const data = await loadLayer(layerId);
        if (!data || !data.enabled) return;

        const allActive = [];
        const ids = await getLayerList();
        for (const id of ids) {
            const layer = await loadLayer(id);
            if (layer && layer.enabled) allActive.push({ id, data: layer });
        }
        allActive.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));

        const idx = allActive.findIndex(l => l.id === layerId);
        if (idx <= 0) return;

        const prev = allActive[idx - 1];
        const tmp = prev.data.order;
        prev.data.order = allActive[idx].data.order;
        allActive[idx].data.order = tmp;

        await saveLayer(prev.id, prev.data);
        await saveLayer(layerId, allActive[idx].data);

        const chatId = window.__MANAGER__?.getCurrentChatId();
        if (chatId) await syncLorebooksForChat(chatId);
    }

    async function moveLayerDown(layerId) {
        const data = await loadLayer(layerId);
        if (!data || !data.enabled) return;

        const allActive = [];
        const ids = await getLayerList();
        for (const id of ids) {
            const layer = await loadLayer(id);
            if (layer && layer.enabled) allActive.push({ id, data: layer });
        }
        allActive.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));

        const idx = allActive.findIndex(l => l.id === layerId);
        if (idx < 0 || idx >= allActive.length - 1) return;

        const next = allActive[idx + 1];
        const tmp = next.data.order;
        next.data.order = allActive[idx].data.order;
        allActive[idx].data.order = tmp;

        await saveLayer(next.id, next.data);
        await saveLayer(layerId, allActive[idx].data);

        const chatId = window.__MANAGER__?.getCurrentChatId();
        if (chatId) await syncLorebooksForChat(chatId);
    }

    async function syncLorebooksForChat(chatId) {
        if (!chatId) return;

        const allIds = await getLayerList();
        const activeLayers = [];
        for (const id of allIds) {
            const layer = await loadLayer(id);
            if (layer && layer.enabled) {
                activeLayers.push({ id, order: layer.order ?? 0, language: layer.selectedLang || layer.languages?.[0] || 'en' });
            }
        }
        activeLayers.sort((a, b) => a.order - b.order);

        const lorebooks = activeLayers.map(l => ({ id: l.id, language: l.language, order: l.order }));
        await window.__MANAGER__.setModuleSettings('library', { lorebooks });
    }

    async function generateLayerName() {
        const existing = await getLayerList();
        let i = 1;
        while (existing.includes('Lorebook_' + i)) i++;
        return 'Lorebook_' + i;
    }

    async function createLayer(name) {
        if (!name) name = await generateLayerName();
        const data = {
            nodes: [],
            edges: [],
            languages: ['en'],
            selectedLang: 'en',
            name: name,
            enabled: false
        };
        await saveLayer(name, data);
        return name;
    }

    // Загрузка данных текущего слоя
    async function loadData() {
        const currentId = await getCurrentLayerId();
        if (!currentId) return { nodes: [], edges: [], languages: ['ru'], selectedLang: 'ru', name: '' };
        const data = await loadLayer(currentId);
        if (data) {
            if (!data.languages) data.languages = ['ru'];
            if (!data.nodes) data.nodes = [];
            if (!data.edges) data.edges = [];
            if (!data.name) data.name = currentId;
            if (!data.selectedLang) data.selectedLang = data.languages[0] || 'ru';
            if (data.enabled === undefined) data.enabled = false;
            data.nodes = data.nodes.map(n => {
                if (!n.i18n) {
                    n.i18n = {};
                    data.languages.forEach(lang => {
                        n.i18n[lang] = { label: '', description: '', comment: '', category: '' };
                    });
                }
                delete n.label;
                delete n.description;
                delete n.comment;
                delete n.category;
                return n;
            });
            return data;
        }
        // Слой был удалён – сбрасываем ссылку на него
        await setCurrentLayerId(null);
        return { nodes: [], edges: [], languages: ['ru'], selectedLang: 'ru', name: '' };
    }

    async function saveData(nodes, edges, languages, selectedLang) {
        const currentId = await getCurrentLayerId();
        if (!currentId) return;
        const existing = await loadLayer(currentId);
        const data = {
            name: existing?.name || currentId,
            nodes: nodes.map(n => { const { _img, ...rest } = n; return rest; }),
            edges: edges,
            languages: languages,
            selectedLang: selectedLang || existing?.selectedLang || languages[0] || 'ru',
            enabled: existing?.enabled || false
        };
        // Сохраняем order, если он был установлен менеджером лорбуков
        if (existing?.order !== undefined) {
            data.order = existing.order;
        }
        await saveLayer(currentId, data);
    }

    const generateId = () => 'n' + Date.now() + Math.random().toString(36).slice(2, 6);

    function createNode(label, x, y, languages) {
        const i18n = {};
        (languages || ['ru']).forEach(lang => {
            i18n[lang] = { label: label || '', description: '', comment: '', category: '' };
        });
        return {
            id: generateId(), x: x || 100, y: y || 100,
            active: true, priority: 10, weight: 100, chance: 100,
            color: '#cba6f7', textColor: '#000080', image: '', size: 30,
            alwaysActive: false, caseSensitive: false, wholeWords: false, oocInstruction: false,
            condition: 'AND_ANY', additionalKeys: [], groups: [],
            scanDepth: 3, recursionDepth: 0, keywords: [],
            insertPosition: 'start', insertTarget: 'llm',
            i18n: i18n, _img: null
        };
    }

    const isBase64Image = str => typeof str === 'string' && str.startsWith('data:image/');
    const isEmoji = str => typeof str === 'string' && str.length > 0 && str.length <= 10 && !isBase64Image(str) && !/[\x00-\x1F]/.test(str);

    function measureEmoji(emoji, fontSize) {
        const c = document.createElement('canvas');
        const ctx = c.getContext('2d');
        ctx.font = fontSize + 'px sans-serif';
        const m = ctx.measureText(emoji);
        return { width: m.width, height: fontSize * 1.2 };
    }

    function fitEmojiToContainer(emoji, container) {
        const baseSize = 100;
        const { width, height } = measureEmoji(emoji, baseSize);
        const scale = Math.min(96 / width, 96 / height);
        const finalSize = Math.floor(baseSize * scale);
        const span = document.createElement('span');
        span.textContent = emoji;
        span.style.cssText = `font-size:${Math.max(finalSize, 12)}px;line-height:1;display:flex;align-items:center;justify-content:center;width:100%;height:100%;`;
        container.appendChild(span);
    }

    const COLORS = [
        '#000000', '#1a1a1a', '#333333', '#4d4d4d', '#666666', '#808080', '#999999', '#b3b3b3',
        '#cccccc', '#e6e6e6', '#ffffff', '#ff0000', '#e60000', '#cc0000', '#b30000', '#990000', '#800000',
        '#ff3333', '#ff6666', '#ff9999', '#ffcccc', '#ffebeb', '#ff6600', '#e65c00', '#cc5200', '#b34700', '#993d00',
        '#ff8800', '#ffaa33', '#ffcc66', '#ffeb99', '#fff5cc', '#00cc00', '#00b300', '#009900', '#008000', '#006600',
        '#33cc33', '#66cc66', '#99cc99', '#ccffcc', '#e6ffe6', '#0000ff', '#0000e6', '#0000cc', '#0000b3', '#000099', '#000080',
        '#3333ff', '#6666ff', '#9999ff', '#ccccff', '#e6e6ff', '#9900ff', '#8800e6', '#7700cc', '#6600b3', '#550099', '#440080',
        '#aa44ff', '#bb77ff', '#cc99ff', '#ddbbff', '#f0e6ff', '#ff00ff', '#e600e6', '#cc00cc', '#b300b3', '#990099',
        '#00ffff', '#00e6e6', '#00cccc', '#00b3b3', '#009999'
    ];

    // ─── Отрисовка графа ──────────────────────────────────────────────
    const CARD_W = 90, CARD_H = 120, IMG_AREA = 90, IMG_SIZE = 80, TEXT_H = 30, CARD_RADIUS = 6;

    function drawRoundedRect(ctx, x, y, w, h, r) {
        ctx.beginPath();
        ctx.moveTo(x + r, y);
        ctx.lineTo(x + w - r, y);
        ctx.quadraticCurveTo(x + w, y, x + w, y + r);
        ctx.lineTo(x + w, y + h - r);
        ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
        ctx.lineTo(x + r, y + h);
        ctx.quadraticCurveTo(x, y + h, x, y + h - r);
        ctx.lineTo(x, y + r);
        ctx.quadraticCurveTo(x, y, x + r, y);
        ctx.closePath();
    }

    function pointInCard(node, px, py) {
        return px >= node.x - CARD_W / 2 && px <= node.x + CARD_W / 2 &&
            py >= node.y - CARD_H / 2 && py <= node.y + CARD_H / 2;
    }

    function intersectCardWithLine(from, to) {
        const cx = from.x,
            cy = from.y;
        const halfW = CARD_W / 2,
            halfH = CARD_H / 2;
        const dx = to.x - cx,
            dy = to.y - cy;
        if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) return { x: cx, y: cy };
        let tMin = Infinity;
        if (dx !== 0) {
            for (let t of [(cx - halfW - cx) / dx, (cx + halfW - cx) / dx]) {
                if (t >= 0) {
                    const px = cx + t * dx,
                        py = cy + t * dy;
                    if (py >= cy - halfH && py <= cy + halfH && t < tMin) tMin = t;
                }
            }
        }
        if (dy !== 0) {
            for (let t of [(cy - halfH - cy) / dy, (cy + halfH - cy) / dy]) {
                if (t >= 0) {
                    const px = cx + t * dx,
                        py = cy + t * dy;
                    if (px >= cx - halfW && px <= cx + halfW && t < tMin) tMin = t;
                }
            }
        }
        if (tMin === Infinity || tMin < 0) return { x: cx, y: cy };
        return { x: cx + tMin * dx, y: cy + tMin * dy };
    }

    function getEdgePoints(edge, edgesArray, nodesArray) {
        const from = nodesArray.find(n => n.id === edge.from);
        const to = nodesArray.find(n => n.id === edge.to);
        if (!from || !to) return { from: { x: 0, y: 0 }, to: { x: 0, y: 0 } };
        let pFrom = intersectCardWithLine(from, to);
        let pTo = intersectCardWithLine(to, from);
        const pairEdges = edgesArray.filter(e =>
            (e.from === edge.from && e.to === edge.to) ||
            (e.from === edge.to && e.to === edge.from)
        );
        if (pairEdges.length === 2) {
            const dx = pTo.x - pFrom.x,
                dy = pTo.y - pFrom.y;
            const len = Math.sqrt(dx * dx + dy * dy);
            if (len > 0.01) {
                const perpX = -dy / len,
                    perpY = dx / len;
                const offset = 4;
                const sign = (edge.from === from.id && edge.to === to.id) ? 1 : -1;
                pFrom.x += perpX * offset * sign;
                pFrom.y += perpY * offset * sign;
                pTo.x += perpX * offset * sign;
                pTo.y += perpY * offset * sign;
            }
        }
        return { from: pFrom, to: pTo };
    }

    // ─── Импорт (без сохранения id слоя) ─────────────────────────────
    async function importLibrary() {
        const input = document.createElement('input');
        input.type = 'file';
        input.accept = '.json';
        return new Promise(resolve => {
            input.onchange = async function(e) {
                const file = e.target.files[0];
                if (!file) { resolve(false); return; }
                const reader = new FileReader();
                reader.onload = async function(ev) {
                    try {
                        const imported = JSON.parse(ev.target.result);
                        if (!imported.nodes || !imported.edges) {
                            showDialog({ title: L10N.t('dialog_import_error'), message: L10N.t('dialog_import_error'), type: 'alert', zIndex: 10000009 });
                            resolve(false);
                            return;
                        }

                        let layerName = imported.name || await generateLayerName();
                        const existingLayers = await getLayerList();
                        if (existingLayers.includes(layerName)) {
                            const conflictChoice = await showDialog({
                                title: L10N.t('layer_conflict_title'),
                                message: L10N.t('layer_conflict_msg', { name: layerName }),
                                type: 'choice',
                                options: [L10N.t('layer_conflict_overwrite'), L10N.t('layer_conflict_rename')],
                                zIndex: 10000009
                            });
                            if (conflictChoice === null) { resolve(false); return; }

                            if (conflictChoice === 0) {
                                const { id: _, ...cleanData } = imported;
                                const data = {
                                    ...cleanData,
                                    name: layerName,
                                    nodes: imported.nodes.map(n => ({ ...n, _img: null })),
                                    edges: imported.edges,
                                    languages: imported.languages || ['ru'],
                                    selectedLang: imported.selectedLang || imported.languages?.[0] || 'ru',
                                    enabled: false
                                };
                                await saveLayer(layerName, data);
                                await setCurrentLayerId(layerName);
                                showDialog({ title: L10N.t('dialog_import_title'), message: L10N.t('dialog_import_msg'), type: 'alert', zIndex: 10000009 });
                                resolve(true);
                                return;
                            } else {
                                let newName = layerName + '_2';
                                let counter = 2;
                                while ((await getLayerList()).includes(newName)) {
                                    newName = layerName + '_' + (counter++);
                                }
                                layerName = newName;
                            }
                        }

                        const { id: _, ...cleanData } = imported;
                        const data = {
                            ...cleanData,
                            name: layerName,
                            nodes: imported.nodes.map(n => ({ ...n, _img: null })),
                            edges: imported.edges,
                            languages: imported.languages || ['ru'],
                            selectedLang: imported.selectedLang || imported.languages?.[0] || 'ru',
                            enabled: false
                        };
                        await saveLayer(layerName, data);
                        await setCurrentLayerId(layerName);
                        showDialog({ title: L10N.t('dialog_import_title'), message: L10N.t('dialog_import_msg'), type: 'alert', zIndex: 10000009 });
                        resolve(true);
                    } catch (err) {
                        showDialog({ title: L10N.t('dialog_import_parse_error'), message: L10N.t('dialog_import_parse_error'), type: 'alert', zIndex: 10000009 });
                        resolve(false);
                    }
                };
                reader.readAsText(file);
            };
            input.click();
        });
    }

    // ─── Генерация содержимого спойлера ──────────────────────────────
    function getLibraryContent() {
        return `
            <div style="padding:8px;display:flex;flex-direction:column;gap:6px;">
                <button id="lib-manager-btn" style="background:#89b4fa;color:#111;border:1px solid #6a9bd6;border-radius:4px;padding:6px 12px;font-weight:bold;cursor:pointer;transition:0.15s;">${L10N.t('lorebook_manager_title')}</button>
                <button id="lib-open-editor" style="background:#a6e3a1;color:#111;border:1px solid #8ccf89;border-radius:4px;padding:6px 12px;font-weight:bold;cursor:pointer;transition:0.15s;">${L10N.t('open_editor_btn')}</button>
            </div>
        `;
    }

    // ─── Менеджер лорбуков (адаптирован под мобильные) ─────────────────
    async function openLorebookManager() {
        const existing = document.getElementById('lorebook-manager-overlay');
        if (existing) existing.remove();

        const overlay = document.createElement('div');
        overlay.id = 'lorebook-manager-overlay';
        overlay.className = 'lore-modal-overlay';
        overlay.style.zIndex = '10000001';

        const modal = document.createElement('div');
        modal.className = 'lore-modal-box';
        modal.style.maxWidth = '700px';
        modal.style.maxHeight = '95vh';
        modal.style.width = '95%';
        modal.style.padding = '10px';
        modal.style.display = 'flex';
        modal.style.flexDirection = 'column';

        const titleRow = document.createElement('div');
        titleRow.style.cssText = 'display:flex; align-items:center; justify-content:center; margin-bottom:8px;';

        const title = document.createElement('h3');
        title.className = 'lore-modal-title';
        title.textContent = L10N.t('lorebook_manager_title');
        title.style.margin = '0';

        const helpBtn = document.createElement('button');
        helpBtn.textContent = '❔';
        helpBtn.title = L10N.t('lorebook_manager_msg');
        helpBtn.style.cssText = `
            width: 28px; height: 28px; border-radius: 50%; border: 1px solid #45475a;
            background: #313244; color: #cdd6f4; font-size: 16px;
            cursor: pointer; display: flex; align-items: center; justify-content: center;
            margin-left: 8px; transition: 0.15s; flex-shrink: 0;
        `;
        helpBtn.addEventListener('mouseenter', () => { helpBtn.style.background = '#45475a'; });
        helpBtn.addEventListener('mouseleave', () => { helpBtn.style.background = '#313244'; });
        helpBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            showLorebookManagerTooltip(helpBtn);
        });

        titleRow.appendChild(title);
        titleRow.appendChild(helpBtn);
        modal.appendChild(titleRow);

        const activeTitle = document.createElement('div');
        activeTitle.textContent = L10N.t('lorebook_active_title');
        activeTitle.style.cssText = 'font-weight:bold; color:#89b4fa; margin-bottom:6px;';
        modal.appendChild(activeTitle);

        const activeContainer = document.createElement('div');
        activeContainer.id = 'lorebook-active-container';
        activeContainer.style.cssText = 'max-height:200px; overflow-y:auto; margin-bottom:16px; border-bottom:2px solid #45475a; padding-bottom:8px;';
        modal.appendChild(activeContainer);

        const allTitle = document.createElement('div');
        allTitle.textContent = L10N.t('lorebook_inactive_title');
        allTitle.style.cssText = 'font-weight:bold; color:#a6adc8; margin-bottom:6px;';
        modal.appendChild(allTitle);

        const allContainer = document.createElement('div');
        allContainer.id = 'lorebook-all-container';
        allContainer.style.cssText = 'flex:1; overflow-y:auto; margin-bottom:12px;';
        modal.appendChild(allContainer);

        const buttonRow = document.createElement('div');
        buttonRow.style.cssText = 'display:flex; justify-content:center; gap:8px; margin-top:8px;';

        const importBtn = document.createElement('button');
        importBtn.className = 'lore-btn lore-btn-warning';
        importBtn.textContent = L10N.t('import_btn');
        importBtn.style.cssText = 'padding:6px 12px; font-size:14px;';
        importBtn.addEventListener('click', async () => {
            const success = await importLibrary();
            if (success) {
                await renderLorebookLists(activeContainer, allContainer);
            }
        });
        buttonRow.appendChild(importBtn);

        const closeBtn = document.createElement('button');
        closeBtn.className = 'lore-btn lore-btn-ghost';
        closeBtn.textContent = L10N.t('close_btn');
        closeBtn.style.alignSelf = 'center';
        closeBtn.addEventListener('click', () => overlay.remove());
        buttonRow.appendChild(closeBtn);

        modal.appendChild(buttonRow);

        overlay.appendChild(modal);
        document.body.appendChild(overlay);

        const chatId = window.__MANAGER__?.getCurrentChatId();

        async function renderLorebookLists(activeCont, allCont) {
            const allIds = await getLayerList();
            const layers = [];
            for (const id of allIds) {
                const data = await loadLayer(id);
                if (data) layers.push({ id, data });
            }

            let activeLorebooks = [];
            if (chatId) {
                const libSettings = await window.__MANAGER__.getModuleSettings('library');
                activeLorebooks = libSettings?.lorebooks || [];
            }
            const activeIds = new Set(activeLorebooks.map(lb => lb.id));

            const activeLayers = layers.filter(l => activeIds.has(l.id));
            const inactiveLayers = layers.filter(l => !activeIds.has(l.id));

            activeLayers.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
            inactiveLayers.sort((a, b) => (a.data.name || a.id).localeCompare(b.data.name || b.id));

            activeCont.innerHTML = '';
            if (activeLayers.length === 0) {
                activeCont.innerHTML = `<div style="text-align:center;color:#a6adc8;">${L10N.t('lorebook_no_active')}</div>`;
            } else {
                activeLayers.forEach(({ id, data }, index) => {
                    const row = createActiveRow(id, data, index === 0, index === activeLayers.length - 1, activeCont, allCont);
                    activeCont.appendChild(row);
                });
            }

            allCont.innerHTML = '';
            if (inactiveLayers.length === 0) {
                allCont.innerHTML = `<div style="text-align:center;color:#a6adc8;">${L10N.t('lorebook_all_active')}</div>`;
            } else {
                inactiveLayers.forEach(({ id, data }) => {
                    const row = createInactiveRow(id, data, activeCont, allCont);
                    allCont.appendChild(row);
                });
            }
        }

        function createActiveRow(id, data, isFirst, isLast, activeCont, allCont) {
            const row = document.createElement('div');
            row.style.cssText = `
                display:flex; align-items:center; gap:6px; padding:6px 8px;
                border-bottom:1px solid #313244; background:#1e1e2e;
            `;

            const arrowContainer = document.createElement('div');
            arrowContainer.style.cssText = 'display:flex; gap:2px;';

            const upBtn = document.createElement('button');
            upBtn.textContent = '▲';
            upBtn.title = L10N.t('move_up');
            upBtn.className = 'lore-btn lore-btn-sm';
            upBtn.style.cssText = `width:32px; height:32px; display:flex; align-items:center; justify-content:center; font-size:20px; padding:4px; ${isFirst ? 'opacity:0.4; cursor:not-allowed;' : ''}`;
            upBtn.disabled = isFirst;
            upBtn.addEventListener('click', async () => {
                await moveLayerUp(id);
                await renderLorebookLists(activeCont, allCont);
            });

            const downBtn = document.createElement('button');
            downBtn.textContent = '▼';
            downBtn.title = L10N.t('move_down');
            downBtn.className = 'lore-btn lore-btn-sm';
            downBtn.style.cssText = `width:32px; height:32px; display:flex; align-items:center; justify-content:center; font-size:20px; padding:4px; ${isLast ? 'opacity:0.4; cursor:not-allowed;' : ''}`;
            downBtn.disabled = isLast;
            downBtn.addEventListener('click', async () => {
                await moveLayerDown(id);
                await renderLorebookLists(activeCont, allCont);
            });

            arrowContainer.appendChild(upBtn);
            arrowContainer.appendChild(downBtn);
            row.appendChild(arrowContainer);

            const nameSpan = document.createElement('span');
            nameSpan.textContent = data.name || id;
            nameSpan.style.cssText = 'flex:1; font-weight:bold; color:#cdd6f4;';
            row.appendChild(nameSpan);

            const langSelect = document.createElement('select');
            langSelect.style.cssText = 'background:#11111b;color:#cdd6f4;border:1px solid #45475a;border-radius:4px;padding:2px 6px;';
            (data.languages || ['en']).forEach(lang => {
                const opt = document.createElement('option');
                opt.value = lang;
                opt.textContent = ISO_LANGUAGES_NATIVE[lang] || lang;
                if (lang === (data.selectedLang || data.languages[0])) opt.selected = true;
                langSelect.appendChild(opt);
            });
            langSelect.title = L10N.t('lorebook_language');
            langSelect.addEventListener('change', async () => {
                data.selectedLang = langSelect.value;
                await saveLayer(id, data);
            });
            row.appendChild(langSelect);

            const deactivateBtn = document.createElement('button');
            deactivateBtn.textContent = '⏏︎';
            deactivateBtn.title = L10N.t('remove_from_active');
            deactivateBtn.className = 'lore-btn lore-btn-danger lore-btn-sm';
            deactivateBtn.style.cssText = 'padding:0px 10px; font-size:24px;';
            deactivateBtn.addEventListener('click', async () => {
                data.enabled = false;
                await saveLayer(id, data);
                if (chatId) {
                    const libSettings = await window.__MANAGER__.getModuleSettings('library');
                    const lorebooks = (libSettings?.lorebooks || []).filter(lb => lb.id !== id);
                    await window.__MANAGER__.setModuleSettings('library', { lorebooks });
                }
                await syncLorebooksForChat(chatId);
                await renderLorebookLists(activeCont, allCont);
            });
            row.appendChild(deactivateBtn);

            return row;
        }

        function createInactiveRow(id, data, activeCont, allCont) {
            const row = document.createElement('div');
            row.style.cssText = `
                display:flex; align-items:center; gap:6px; padding:6px 8px;
                border-bottom:1px solid #313244;
            `;

            const activateArea = document.createElement('div');
            activateArea.title = L10N.t('add_to_active');
            activateArea.style.cssText = `
                flex:1; display:flex; align-items:center;
                background:#313244; border:1px solid #45475a; border-radius:4px;
                padding:6px 9px; cursor:pointer; transition:0.15s;
                color:#cdd6f4; font-weight:bold;
            `;
            activateArea.addEventListener('mouseenter', () => {
                activateArea.style.background = '#45475a';
                activateArea.style.borderColor = '#89b4fa';
            });
            activateArea.addEventListener('mouseleave', () => {
                activateArea.style.background = '#313244';
                activateArea.style.borderColor = '#45475a';
            });
            activateArea.addEventListener('click', async () => {
                if (!chatId) return;
                data.enabled = true;
                const allIds = await getLayerList();
                let maxOrder = 0;
                for (const lid of allIds) {
                    const ldata = await loadLayer(lid);
                    if (ldata && ldata.enabled) {
                        maxOrder = Math.max(maxOrder, ldata.order || 0);
                    }
                }
                data.order = maxOrder + 1;
                await saveLayer(id, data);
                await syncLorebooksForChat(chatId);
                await renderLorebookLists(activeCont, allCont);
            });

            const nameSpan = document.createElement('span');
            nameSpan.textContent = data.name || id;
            nameSpan.style.cssText = 'flex:1;';
            activateArea.appendChild(nameSpan);
            row.appendChild(activateArea);

            const delBtn = document.createElement('button');
            delBtn.textContent = '🗑️';
            delBtn.title = L10N.t('lorebook_delete');
            delBtn.className = 'lore-btn lore-btn-danger lore-btn-sm';
            delBtn.style.cssText = 'padding:6px 9px; font-size:18px; flex-shrink:0;';
            delBtn.addEventListener('click', async (e) => {
                e.stopPropagation();
                const confirmed = await showDialog({
                    title: L10N.t('dialog_confirm_delete_title'),
                    message: L10N.t('dialog_confirm_delete_msg', { name: data.name || id }),
                    type: 'confirm',
                    zIndex: 10000011
                });
                if (!confirmed) return;
                await deleteLayer(id);
                if (chatId) {
                    const libSettings = await window.__MANAGER__.getModuleSettings('library');
                    const lorebooks = (libSettings?.lorebooks || []).filter(lb => lb.id !== id);
                    await window.__MANAGER__.setModuleSettings('library', { lorebooks });
                }
                await renderLorebookLists(activeCont, allCont);
            });
            row.appendChild(delBtn);

            return row;
        }

        function showLorebookManagerTooltip(anchor) {
            const old = document.getElementById('lorebook-manager-tooltip');
            if (old) { old.remove(); return; }

            const modalRect = modal.getBoundingClientRect();
            const tooltip = document.createElement('div');
            tooltip.id = 'lorebook-manager-tooltip';
            tooltip.textContent = L10N.t('lorebook_manager_msg');
            tooltip.style.cssText = `
                position: fixed;
                top: ${modalRect.top + 40}px;
                left: ${modalRect.left + 10}px;
                width: ${modalRect.width - 20}px;
                max-height: 200px;
                overflow-y: auto;
                background: #1e1e2e;
                border: 1px solid #45475a;
                border-radius: 6px;
                padding: 10px;
                color: #cdd6f4;
                font-size: 13px;
                line-height: 1.5;
                z-index: 10000020;
                box-shadow: 0 4px 12px rgba(0,0,0,0.6);
                white-space: pre-line;
            `;
            document.body.appendChild(tooltip);

            setTimeout(() => {
                document.addEventListener('click', function handler(e) {
                    if (!tooltip.contains(e.target) && e.target !== anchor) {
                        tooltip.remove();
                        document.removeEventListener('click', handler);
                    }
                });
            }, 0);
        }

        await renderLorebookLists(activeContainer, allContainer);

        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) overlay.remove();
        });
    }

    // ─── Универсальный диалог ─────────────────────────────────────────
    function showDialog(options) {
        return new Promise(resolve => {
            const {
                title = '',
                message = '',
                type = 'alert',
                defaultValue = '',
                placeholder = '',
                confirmText = L10N.t('dialog_ok'),
                cancelText = L10N.t('dialog_cancel'),
                inputType = 'text',
                inputMaxLength,
                zIndex = 10000009,
                options: choiceOptions = []
            } = options;

            const overlay = document.createElement('div');
            overlay.style.cssText = `position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);z-index:${zIndex};display:flex;justify-content:center;align-items:center;font-family:sans-serif;`;
            const box = document.createElement('div');
            box.style.cssText = `background:#1e1e2e;padding:10px;border-radius:16px;border:2px solid #cba6f7;max-width:420px;width:95%;max-height:95vh;color:#cdd6f4;box-shadow:0 8px 32px rgba(0,0,0,0.9);position:relative;`;

            if (title) {
                const h = document.createElement('h3');
                h.textContent = title;
                h.style.cssText = 'margin:0 0 12px 0;color:#cba6f7;font-size:18px;text-align:center;';
                box.appendChild(h);
            }
            if (message) {
                const m = document.createElement('div');
                m.textContent = message;
                m.style.cssText = 'margin-bottom:16px;font-size:14px;line-height:1.5;text-align:center;word-break:break-word;';
                box.appendChild(m);
            }

            let input = null;
            if (type === 'prompt') {
                input = document.createElement('input');
                input.type = inputType;
                input.value = defaultValue;
                input.placeholder = placeholder || '';
                if (inputMaxLength !== undefined && inputMaxLength > 0) {
                    input.maxLength = inputMaxLength;
                }
                input.style.cssText = 'width:100%;padding:8px;background:#11111b;border:1px solid #45475a;color:#cdd6f4;border-radius:6px;font-size:14px;box-sizing:border-box;margin-bottom:16px;';
                box.appendChild(input);
                setTimeout(() => input.focus(), 50);
            }

            const btnContainer = document.createElement('div');
            btnContainer.style.cssText = 'display:flex;justify-content:center;gap:12px;margin-top:8px;flex-wrap:wrap;';

            if (type === 'choice') {
                choiceOptions.forEach((text, index) => {
                    const btn = document.createElement('button');
                    btn.textContent = text;
                    btn.style.cssText = 'background:#a6e3a1;color:#111;border:none;border-radius:6px;padding:8px 24px;font-weight:bold;cursor:pointer;font-size:14px;transition:0.2s;';
                    btn.onmouseenter = () => btn.style.background = '#8ccf89';
                    btn.onmouseleave = () => btn.style.background = '#a6e3a1';
                    btn.onclick = () => {
                        document.removeEventListener('keydown', keyHandler);
                        overlay.remove();
                        resolve(index);
                    };
                    btnContainer.appendChild(btn);
                });
                const cancelBtn = document.createElement('button');
                cancelBtn.textContent = L10N.t('dialog_cancel');
                cancelBtn.style.cssText = 'background:#f38ba8;color:#111;border:none;border-radius:6px;padding:8px 24px;font-weight:bold;cursor:pointer;font-size:14px;';
                cancelBtn.onmouseenter = () => cancelBtn.style.background = '#e06c8a';
                cancelBtn.onmouseleave = () => cancelBtn.style.background = '#f38ba8';
                cancelBtn.onclick = () => {
                    document.removeEventListener('keydown', keyHandler);
                    overlay.remove();
                    resolve(null);
                };
                btnContainer.appendChild(cancelBtn);
                box.appendChild(btnContainer);
                overlay.appendChild(box);
                document.body.appendChild(overlay);
                const keyHandler = (e) => {
                    if (e.key === 'Escape') {
                        document.removeEventListener('keydown', keyHandler);
                        overlay.remove();
                        resolve(null);
                    }
                };
                document.addEventListener('keydown', keyHandler);
                return;
            }

            const okBtn = document.createElement('button');
            okBtn.textContent = confirmText;
            okBtn.style.cssText = 'background:#a6e3a1;color:#111;border:none;border-radius:6px;padding:8px 24px;font-weight:bold;cursor:pointer;font-size:14px;transition:0.2s;';
            okBtn.onmouseenter = () => okBtn.style.background = '#8ccf89';
            okBtn.onmouseleave = () => okBtn.style.background = '#a6e3a1';
            let cancelBtn = null;
            if (type === 'confirm' || type === 'prompt') {
                cancelBtn = document.createElement('button');
                cancelBtn.textContent = cancelText;
                cancelBtn.style.cssText = 'background:#f38ba8;color:#111;border:none;border-radius:6px;padding:8px 24px;font-weight:bold;cursor:pointer;font-size:14px;transition:0.2s;';
                cancelBtn.onmouseenter = () => cancelBtn.style.background = '#e06c8a';
                cancelBtn.onmouseleave = () => cancelBtn.style.background = '#f38ba8';
                btnContainer.appendChild(cancelBtn);
            }
            btnContainer.appendChild(okBtn);
            box.appendChild(btnContainer);
            overlay.appendChild(box);
            document.body.appendChild(overlay);

            const cleanup = (result) => { overlay.remove(); resolve(result); };
            const keyHandler = (e) => {
                if (e.key === 'Enter') {
                    if (type === 'prompt') cleanup(input.value);
                    else if (type === 'confirm') cleanup(true);
                    else cleanup(undefined);
                } else if (e.key === 'Escape') {
                    if (type === 'prompt') cleanup(null);
                    else if (type === 'confirm') cleanup(false);
                    else cleanup(undefined);
                }
            };
            document.addEventListener('keydown', keyHandler);
            okBtn.onclick = () => {
                document.removeEventListener('keydown', keyHandler);
                if (type === 'prompt') cleanup(input.value);
                else if (type === 'confirm') cleanup(true);
                else cleanup(undefined);
            };
            if (cancelBtn) {
                cancelBtn.onclick = () => {
                    document.removeEventListener('keydown', keyHandler);
                    if (type === 'prompt') cleanup(null);
                    else cleanup(false);
                };
            }
            overlay.onclick = (e) => {
                if (e.target === overlay) {
                    document.removeEventListener('keydown', keyHandler);
                    if (type === 'prompt') cleanup(null);
                    else if (type === 'confirm') cleanup(false);
                    else cleanup(undefined);
                }
            };
        });
    }

    // ─── Основной редактор (с touch-поддержкой) ─────────────────────────
async function openGraphEditor() {
        if (document.getElementById('lore-graph-editor')) return;

        let data = await loadData();
        let languages = data.languages || ['ru'];
        let nodes = data.nodes.map(n => ({ ...n, _img: null }));
        let edges = data.edges.map(e => ({ ...e }));
        let currentLayerName = data.name || await getCurrentLayerId();

        let currentEditLang = data.selectedLang || languages[0] || 'ru';

        let bgColor = await loadBgColor();
        let gridColor = await loadGridColor();

        let redrawScheduled = false,
            animationFrameId = null;
        const requestRedraw = () => {
            if (redrawScheduled) return;
            redrawScheduled = true;
            if (animationFrameId) cancelAnimationFrame(animationFrameId);
            animationFrameId = requestAnimationFrame(() => {
                redrawScheduled = false;
                animationFrameId = null;
                draw();
                updateZoomButtons();
            });
        };

        const getNodeLang = (node, lang) => {
            if (!node.i18n) node.i18n = {};
            if (!node.i18n[lang]) node.i18n[lang] = { label: '', description: '', comment: '', category: '' };
            return node.i18n[lang];
        };
        const setNodeLang = (node, lang, label, description, comment, category) => {
            if (!node.i18n) node.i18n = {};
            if (!node.i18n[lang]) node.i18n[lang] = {};
            const l = node.i18n[lang];
            if (label !== undefined) l.label = label;
            if (description !== undefined) l.description = description;
            if (comment !== undefined) l.comment = comment;
            if (category !== undefined) l.category = category;
        };

        const saveGraphData = () => {
            saveData(nodes, edges, languages, currentEditLang).catch(err => {
                console.error('Save failed:', err);
                window.__MANAGER__?.logError('library', 'Save failed: ' + err);
                showDialog({ title: 'Ошибка', message: 'Не удалось сохранить данные. Возможно, хранилище переполнено.', type: 'alert', zIndex: 10000009 });
            });
        };

        const getOutgoing = nodeId => edges.filter(e => e.from === nodeId).map(e => e.to);
        const getIncoming = nodeId => edges.filter(e => e.to === nodeId).map(e => e.from);
        const removeAllEdgesBetween = (a, b) => { edges = edges.filter(e => !((e.from === a && e.to === b) || (e.from === b && e.to === a))); };
        const addEdge = (from, to) => { const edge = { id: generateId(), from, to }; edges.push(edge); return edge; };

        const addLanguage = newLang => {
            if (languages.includes(newLang) || newLang.trim() === '') return false;
            languages.push(newLang);
            nodes.forEach(node => {
                if (!node.i18n) node.i18n = {};
                node.i18n[newLang] = { label: '', description: '', comment: '', category: '' };
            });
            currentEditLang = newLang;
            saveGraphData();
            updateLangButtons();
            return true;
        };

        const removeLanguage = lang => {
            if (languages.length <= 1) return false;
            const idx = languages.indexOf(lang);
            if (idx === -1) return false;
            nodes.forEach(node => {
                if (node.i18n && node.i18n[lang]) delete node.i18n[lang];
            });
            languages.splice(idx, 1);
            if (currentEditLang === lang) {
                currentEditLang = languages[0] || 'ru';
            }
            saveGraphData();
            updateLangButtons();
            return true;
        };

        const updateLangButtons = () => {
            document.querySelectorAll('.lore-lang-btn').forEach(btn => {
                btn.textContent = currentEditLang.toUpperCase();
                btn.title = ISO_LANGUAGES_NATIVE[currentEditLang] || currentEditLang;
            });
        };

        const getNodeLabel = (id) => {
            const n = nodes.find(n => n.id === id);
            if (!n) return id;
            const langData = getNodeLang(n, currentEditLang);
            return langData.label || n.id;
        };

        const panelRows = [
            [
                { id: 'active', type: 'checkbox', label: 'prop_active', tooltip: 'active', default: true },
                { id: 'card_color', type: 'colorbtn', label: 'prop_card_color' },
                { id: 'priority', type: 'number', label: 'prop_priority', tooltip: 'priority', default: 1, width: 45 },
                { id: 'chance', type: 'number', label: 'prop_chance', tooltip: 'chance', default: 100, min: 0, max: 199, width: 60 }
            ],
            [
                { id: 'id', type: 'text', label: 'prop_id', readonly: true }
            ],
            [
                { id: '_label_text_color', type: 'label_only', label: 'prop_text_color', tooltip: 'text_color' }
            ],
            [
                { id: 'label', type: 'text', label: '', color: true, lang: true, placeholder: 'prop_label_placeholder', stretch: true }
            ],
            [
                { id: '_label_desc', type: 'label_only', label: 'prop_description', tooltip: 'description', extraCheckbox: 'alwaysActive', tooltipCheckbox: 'alwaysActive' }
            ],
            [
                { id: 'desc', type: 'textarea', rows: 2, lang: true }
            ],
            [
                { id: '_label_comment', type: 'label_only', label: 'prop_comment', tooltip: 'comment' }
            ],
            [
                { id: 'comment', type: 'textarea', rows: 2, lang: true }
            ],
            [
                { id: 'category', type: 'autocomplete', label: 'prop_category', tooltip: 'category', lang: true, autocomplete: 'category', placeholder: 'prop_category_placeholder', stretch: true }
            ],
            [
                { id: '_label_keywords', type: 'label_only', label: 'prop_keywords', tooltip: 'keywords' }
            ],
            [
                { id: 'keywords', type: 'textarea', rows: 1 }
            ],
            [
                { id: 'caseSensitive', type: 'checkbox', label: 'prop_caseSensitive', tooltip: 'caseSensitive', default: false },
                { id: 'wholeWords', type: 'checkbox', label: 'prop_wholeWords', tooltip: 'wholeWords', default: false }
            ],
            [
                { id: 'condition', type: 'select', label: 'prop_condition', tooltip: 'condition', options: ['condition_and_any', 'condition_and_all', 'condition_not_all', 'condition_not_any'] }
            ],
            [
                { id: '_label_additional_keys', type: 'label_only', label: 'prop_additional_keys', tooltip: 'additional_keys' }
            ],
            [
                { id: 'additional_keys', type: 'textarea', rows: 1 }
            ],
            [
                { id: '_label_groups', type: 'label_only', label: 'prop_groups', tooltip: 'groups' },
                { id: 'weight', type: 'number', label: 'prop_weight', tooltip: 'weight', default: 100, width: 60 }
            ],
            [
                { id: 'groups', type: 'autocomplete', label: '', autocomplete: 'groups', placeholder: '', stretch: true }
            ],
            [
                { id: 'scan_depth', type: 'number', label: 'prop_scan_depth', tooltip: 'scan_depth', default: 0, width: 45 },
                { id: 'recursion_depth', type: 'number', label: 'prop_recursion_depth', tooltip: 'recursion_depth', default: 0, width: 45 }
            ],
            [
                { id: 'insertTarget', type: 'select', label: 'prop_insert_target', tooltip: 'insert_target', options: ['insert_target_llm',
                                                                                                                       'insert_target_last_message',
                                                                                                                    // 'insert_target_summary',
                                                                                                                       'insert_target_user_appearance',
                                                                                                                       'insert_target_prefill'] },
                { id: 'insertPosition', type: 'select', label: 'prop_insert_position', tooltip: 'insert_position', options: ['insert_position_start', 'insert_position_replace', 'insert_position_end'] },
                { id: 'oocInstruction', type: 'checkbox', label: 'prop_ooc_instruction', tooltip: 'ooc_instruction' }
            ]
        ];

        const modal = document.createElement('div');
        modal.id = 'lore-graph-editor';
        modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:10000000;display:flex;flex-direction:column;font-family:sans-serif;color:#cdd6f4;';

        const toolbar = document.createElement('div');
        toolbar.style.cssText = 'background:#1e1e2e;color:#cdd6f4;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:2px solid #cba6f7;flex-shrink:0;flex-wrap:wrap;';

        const leftSection = document.createElement('div');
        leftSection.style.cssText = 'display:flex;align-items:center;gap:8px;flex-shrink:0;';

        const titleSpan = document.createElement('span');
        titleSpan.style.cssText = 'font-size:20px;font-weight:bold;color:#cba6f7;';
        titleSpan.textContent = '🌐 ' + L10N.t('editor_title');
        leftSection.appendChild(titleSpan);

        const layerSelect = document.createElement('select');
        layerSelect.id = 'layer-select';
        layerSelect.className = 'lore-layer-select';
        layerSelect.style.marginLeft = '12px';
        leftSection.appendChild(layerSelect);

        const addLayerBtn = document.createElement('button');
        addLayerBtn.id = 'add-layer-btn';
        addLayerBtn.className = 'lore-layer-btn';
        addLayerBtn.textContent = L10N.t('layer_create');
        addLayerBtn.title = L10N.t('layer_create');
        leftSection.appendChild(addLayerBtn);

        const delLayerBtn = document.createElement('button');
        delLayerBtn.id = 'del-layer-btn';
        delLayerBtn.className = 'lore-layer-btn lore-layer-btn-danger';
        delLayerBtn.textContent = L10N.t('layer_delete');
        delLayerBtn.title = L10N.t('layer_delete');
        leftSection.appendChild(delLayerBtn);

        const renameBtn = document.createElement('button');
        renameBtn.id = 'rename-layer-btn';
        renameBtn.className = 'lore-layer-btn';
        renameBtn.textContent = L10N.t('layer_rename_btn');
        renameBtn.title = L10N.t('layer_renamed');
        leftSection.appendChild(renameBtn);

        toolbar.appendChild(leftSection);

        const rightSection = document.createElement('div');
        rightSection.style.cssText = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;';
        rightSection.innerHTML = `
            <button id="graph-bg-btn" class="lore-toolbar-btn" title="${L10N.t('bg_color_label')}">${L10N.t('bg_color_label')} <span class="lore-color-swatch" style="background:${bgColor};"></span></button>
            <button id="graph-grid-btn" class="lore-toolbar-btn" title="${L10N.t('grid_color_label')}">${L10N.t('grid_color_label')} <span class="lore-color-swatch" style="background:${gridColor};"></span></button>
            <span class="toolbar-separator" style="color:#45475a;margin:0 2px;">|</span>
            <button id="graph-export-btn" class="lore-btn lore-btn-primary">${L10N.t('export_btn')}</button>
            <button id="graph-import-btn" class="lore-btn lore-btn-warning">${L10N.t('import_btn')}</button>
            <button id="graph-close-btn" class="lore-btn lore-btn-danger">${L10N.t('close_btn')}</button>
        `;
        toolbar.appendChild(rightSection);
        modal.appendChild(toolbar);

        const mainContainer = document.createElement('div');
        mainContainer.style.cssText = 'display:flex;flex:1;overflow:hidden;';

        const canvasContainer = document.createElement('div');
        canvasContainer.style.cssText = 'flex:1;position:relative;background:#11111b;';
        const canvas = document.createElement('canvas');
        canvas.id = 'graph-canvas';
        canvas.style.cssText = 'display:block;width:100%;height:100%;';
        canvasContainer.appendChild(canvas);

        // Кнопки зума на холсте
        const zoomControls = document.createElement('div');
        zoomControls.id = 'zoom-controls';
        zoomControls.style.cssText = `
            position: absolute;
            left: 10px;
            top: 10px;
            display: flex;
            flex-direction: column;
            gap: 4px;
            z-index: 10;
        `;

        const ZOOM_STEP = 1.2;
        const createZoomButton = (text, onClick) => {
            const btn = document.createElement('button');
            btn.textContent = text;
            btn.style.cssText = `
                width: 32px;
                height: 32px;
                border-radius: 6px;
                border: 1px solid #45475a;
                background: #313244;
                color: #cdd6f4;
                font-size: 20px;
                font-weight: bold;
                cursor: pointer;
                display: flex;
                align-items: center;
                justify-content: center;
                transition: 0.15s;
                user-select: none;
            `;
            btn.addEventListener('mouseenter', () => { if (!btn.disabled) { btn.style.background = '#45475a'; btn.style.borderColor = '#89b4fa'; } });
            btn.addEventListener('mouseleave', () => { if (!btn.disabled) { btn.style.background = '#313244'; btn.style.borderColor = '#45475a'; } });
            btn.addEventListener('click', onClick);
            return btn;
        };

        const zoomInBtn = createZoomButton('+', () => {
            if (scale >= 5) return;
            const newScale = Math.min(scale * ZOOM_STEP, 5);
            const rect = canvas.getBoundingClientRect();
            const cx = rect.width / 2;
            const cy = rect.height / 2;
            const worldX = (cx - offsetX) / scale;
            const worldY = (cy - offsetY) / scale;
            offsetX = cx - worldX * newScale;
            offsetY = cy - worldY * newScale;
            scale = newScale;
            requestRedraw();
        });

        const zoomOutBtn = createZoomButton('–', () => {
            if (scale <= 0.04) return;
            const newScale = Math.max(scale / ZOOM_STEP, 0.04);
            const rect = canvas.getBoundingClientRect();
            const cx = rect.width / 2;
            const cy = rect.height / 2;
            const worldX = (cx - offsetX) / scale;
            const worldY = (cy - offsetY) / scale;
            offsetX = cx - worldX * newScale;
            offsetY = cy - worldY * newScale;
            scale = newScale;
            requestRedraw();
        });

        zoomControls.appendChild(zoomInBtn);
        zoomControls.appendChild(zoomOutBtn);
        canvasContainer.appendChild(zoomControls);

        function updateZoomButtons() {
            zoomInBtn.disabled = scale >= 5;
            zoomOutBtn.disabled = scale <= 0.04;
            [zoomInBtn, zoomOutBtn].forEach(btn => {
                btn.style.opacity = btn.disabled ? '0.4' : '1';
                btn.style.cursor = btn.disabled ? 'default' : 'pointer';
            });
        }

        mainContainer.appendChild(canvasContainer);

        const resizeHandle = document.createElement('div');
        resizeHandle.id = 'resize-handle';
        resizeHandle.style.cssText = 'width:6px;background:transparent;cursor:col-resize;flex-shrink:0;transition:0.15s;position:relative;z-index:5;';
        resizeHandle.addEventListener('mouseenter', () => resizeHandle.style.background = '#45475a');
        resizeHandle.addEventListener('mouseleave', () => { if (!resizeHandle._dragging) resizeHandle.style.background = 'transparent'; });
        mainContainer.appendChild(resizeHandle);

        const propertiesPanel = document.createElement('div');
        propertiesPanel.id = 'properties-panel';
        const panelWidth = await loadPanelWidth();
        propertiesPanel.style.cssText = `width:${panelWidth}px;background:#1e1e2e;padding:16px;overflow-y:auto;border-left:2px solid #313244;flex-shrink:0;display:flex;flex-direction:column;gap:8px;`;

        propertiesPanel.innerHTML = `
            <div id="property-empty" style="color:#a6adc8;">${L10N.t('empty_property')}</div>
            <div id="property-content" style="display:none;">
                <div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
                    <div class="lore-flex-wrap">
                        <button id="btn-insert-base64" class="lore-btn lore-btn-primary lore-btn-sm">${L10N.t('btn_insert_base64')}</button>
                        <button id="btn-choose-emoji" class="lore-btn lore-btn-warning lore-btn-sm">${L10N.t('btn_choose_emoji')}</button>
                        <button id="btn-clear-image" class="lore-btn lore-btn-danger lore-btn-sm">${L10N.t('btn_clear_image')}</button>
                    </div>
                    <div style="display:flex; align-items:flex-start; gap:8px;">
                        <div id="image-preview-container" class="lore-image-preview-container">
                            <span id="image-preview" class="lore-image-preview">📷</span>
                        </div>
                        <span class="tooltip-icon" data-tooltip="image" style="font-size:18px; cursor:help; color:#89b4fa; margin-top:4px;">ℹ️</span>
                    </div>
                </div>
                <hr style="border-color:#45475a;margin:8px 0;">
                <div id="fields-container"></div>
                <div style="height:20px;"></div>
                <button id="prop-delete-node-btn" class="lore-btn lore-btn-danger" style="width:100%;margin-top:8px;">${L10N.t('delete_node_btn')}</button>
                <hr style="border-color:#45475a;margin:8px 0;">
                <div id="prop-outgoing-title" style="font-weight:bold;color:#89b4fa;">${L10N.t('outgoing_title')}</div>
                <div id="prop-outgoing-list" style="font-size:13px;margin-bottom:6px;"></div>
                <div id="prop-incoming-title" style="font-weight:bold;color:#a6e3a1;">${L10N.t('incoming_title')}</div>
                <div id="prop-incoming-list" style="font-size:13px;"></div>
            </div>
        `;
        mainContainer.appendChild(propertiesPanel);
        modal.appendChild(mainContainer);
        document.body.appendChild(modal);

        const fieldsContainer = document.getElementById('fields-container');
        const fieldElements = {};

        panelRows.forEach(rowFields => {
            const rowContainer = document.createElement('div');
            rowContainer.className = 'lore-flex-row';
            rowContainer.style.cssText = 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:4px;width:100%;';

            rowFields.forEach(f => {
                if (f.type === 'label_only') {
                    const label = document.createElement('label');
                    label.className = 'lore-label';
                    const labelText = L10N.t(f.label);
                    if (f.tooltip) {
                        label.innerHTML = `${labelText}<span class="tooltip-icon" data-tooltip="${f.tooltip}">ℹ️</span>`;
                    } else {
                        label.textContent = labelText;
                    }
                    rowContainer.appendChild(label);
                    if (f.extraCheckbox) {
                        const cbLabel = document.createElement('span');
                        cbLabel.style.display = 'flex';
                        cbLabel.style.alignItems = 'center';
                        cbLabel.style.gap = '4px';
                        const cb = document.createElement('input');
                        cb.type = 'checkbox';
                        cb.className = 'lore-checkbox';
                        cb.id = 'prop-' + f.extraCheckbox;
                        fieldElements[f.extraCheckbox] = cb;
                        cbLabel.appendChild(cb);
                        const span = document.createElement('span');
                        if (f.tooltipCheckbox) {
                            span.innerHTML = `${L10N.t('prop_alwaysActive')}<span class="tooltip-icon" data-tooltip="${f.tooltipCheckbox}">ℹ️</span>`;
                        } else {
                            span.textContent = L10N.t('prop_alwaysActive');
                        }
                        cbLabel.appendChild(span);
                        rowContainer.appendChild(cbLabel);
                    }
                    return;
                }

                if (f.type === 'textarea') {
                    const wrapper = document.createElement('div');
                    wrapper.style.cssText = 'display:flex;align-items:center;gap:4px;width:100%;';
                    const input = document.createElement('textarea');
                    input.className = 'lore-textarea';
                    input.id = 'prop-' + f.id;
                    if (f.rows) input.rows = f.rows;
                    wrapper.appendChild(input);
                    if (f.lang) {
                        const langBtn = document.createElement('button');
                        langBtn.className = 'lore-lang-btn tooltip-icon';
                        langBtn.textContent = currentEditLang.toUpperCase();
                        langBtn.title = ISO_LANGUAGES_NATIVE[currentEditLang] || currentEditLang;
                        langBtn.dataset.target = f.id;
                        langBtn.dataset.tooltip = 'lang_manager';
                        langBtn.style.cursor = 'pointer';
                        langBtn.addEventListener('click', e => { e.stopPropagation(); openLanguageManager(); });
                        wrapper.appendChild(langBtn);
                    }
                    rowContainer.appendChild(wrapper);
                    fieldElements[f.id] = input;
                    return;
                }

                const wrapper = document.createElement('div');
                wrapper.style.display = 'flex';
                wrapper.style.alignItems = 'center';
                wrapper.style.gap = '4px';
                if (f.stretch) wrapper.style.flex = '1';

                if (f.type === 'checkbox') {
                    const cb = document.createElement('input');
                    cb.type = 'checkbox';
                    cb.className = 'lore-checkbox';
                    cb.id = 'prop-' + f.id;
                    fieldElements[f.id] = cb;
                    wrapper.appendChild(cb);

                    const span = document.createElement('span');
                    if (f.tooltip) {
                        span.innerHTML = `${L10N.t(f.label)}<span class="tooltip-icon" data-tooltip="${f.tooltip}">ℹ️</span>`;
                    } else {
                        span.textContent = L10N.t(f.label);
                    }
                    wrapper.appendChild(span);
                    rowContainer.appendChild(wrapper);
                    return;
                }

                const label = document.createElement('label');
                label.className = 'lore-label';
                const labelText = L10N.t(f.label);
                if (f.tooltip) {
                    label.innerHTML = `${labelText}<span class="tooltip-icon" data-tooltip="${f.tooltip}">ℹ️</span>`;
                } else {
                    label.textContent = labelText;
                }
                wrapper.appendChild(label);

                let input = null;
                if (f.type === 'text') {
                    input = document.createElement('input');
                    input.type = 'text';
                    input.className = 'lore-input';
                    input.id = 'prop-' + f.id;
                    if (f.readonly) input.readOnly = true;
                    if (f.placeholder) input.placeholder = L10N.t(f.placeholder);
                    if (f.color) {
                        const colorBtn = document.createElement('button');
                        colorBtn.className = 'lore-card-color-btn';
                        colorBtn.id = 'prop-' + f.id + '-color';
                        colorBtn.style.background = '#000000';
                        colorBtn.addEventListener('click', () => openColorPicker('text', colorBtn.style.background));
                        wrapper.appendChild(colorBtn);
                    }
                    if (f.stretch) input.style.width = '100%';
                    wrapper.appendChild(input);
                    fieldElements[f.id] = input;
                    if (f.lang) {
                        const langBtn = document.createElement('button');
                        langBtn.className = 'lore-lang-btn tooltip-icon';
                        langBtn.textContent = currentEditLang.toUpperCase();
                        langBtn.title = ISO_LANGUAGES_NATIVE[currentEditLang] || currentEditLang;
                        langBtn.dataset.target = f.id;
                        langBtn.dataset.tooltip = 'lang_manager';
                        langBtn.style.cursor = 'pointer';
                        langBtn.addEventListener('click', e => { e.stopPropagation(); openLanguageManager(); });
                        wrapper.appendChild(langBtn);
                    }
                } else if (f.type === 'number') {
                    input = document.createElement('input');
                    input.type = 'number';
                    input.className = 'lore-input';
                    input.id = 'prop-' + f.id;
                    input.style.width = (f.width || 60) + 'px';
                    input.value = f.default || 0;
                    wrapper.appendChild(input);
                    fieldElements[f.id] = input;
                } else if (f.type === 'select') {
                    input = document.createElement('select');
                    input.className = 'lore-input';
                    input.id = 'prop-' + f.id;
                    const defaultVal = f.defaultVal || (f.id === 'condition' ? 'AND_ANY' : (f.id === 'insertTarget' ? 'llm' : (f.id === 'insertPosition' ? 'start' : null)));
                    f.options.forEach(opt => {
                        const option = document.createElement('option');
                        option.value = opt;
                        option.textContent = L10N.t(opt);
                        if (opt === defaultVal) option.selected = true;
                        input.appendChild(option);
                    });
                    if (f.stretch) input.style.width = '100%';
                    wrapper.appendChild(input);
                    fieldElements[f.id] = input;
                } else if (f.type === 'autocomplete') {
                    const wrapperAutocomplete = document.createElement('div');
                    wrapperAutocomplete.style.cssText = 'position:relative;flex:1;';
                    input = document.createElement('input');
                    input.type = 'text';
                    input.className = 'lore-input';
                    input.id = 'prop-' + f.id;
                    input.autocomplete = 'off';
                    if (f.placeholder) input.placeholder = L10N.t(f.placeholder);
                    if (f.stretch) input.style.width = '100%';
                    wrapperAutocomplete.appendChild(input);
                    const dropdown = document.createElement('div');
                    dropdown.id = f.id + '-dropdown';
                    dropdown.style.cssText = 'display:none;position:absolute;top:100%;left:0;right:0;background:#1e1e2e;border:1px solid #45475a;border-top:none;border-radius:0 0 4px 4px;max-height:150px;overflow-y:auto;z-index:101;box-shadow:0 4px 8px rgba(0,0,0,0.4);';
                    const list = document.createElement('div');
                    list.id = f.id + '-list';
                    list.style.cssText = 'padding:4px 0;';
                    dropdown.appendChild(list);
                    wrapperAutocomplete.appendChild(dropdown);
                    wrapper.appendChild(wrapperAutocomplete);
                    fieldElements[f.id] = input;
                    if (f.lang) {
                        const langBtn = document.createElement('button');
                        langBtn.className = 'lore-lang-btn tooltip-icon';
                        langBtn.textContent = currentEditLang.toUpperCase();
                        langBtn.title = ISO_LANGUAGES_NATIVE[currentEditLang] || currentEditLang;
                        langBtn.dataset.target = f.id;
                        langBtn.dataset.tooltip = 'lang_manager';
                        langBtn.style.cursor = 'pointer';
                        langBtn.addEventListener('click', e => { e.stopPropagation(); openLanguageManager(); });
                        wrapper.appendChild(langBtn);
                    }
                } else if (f.type === 'colorbtn') {
                    const colorBtn = document.createElement('button');
                    colorBtn.className = 'lore-card-color-btn';
                    colorBtn.id = 'prop-' + f.id;
                    colorBtn.style.background = '#cba6f7';
                    colorBtn.addEventListener('click', () => openColorPicker('card', colorBtn.style.background));
                    wrapper.appendChild(colorBtn);
                    fieldElements[f.id] = colorBtn;
                }
                rowContainer.appendChild(wrapper);
            });
            fieldsContainer.appendChild(rowContainer);
        });

        document.getElementById('graph-bg-btn').addEventListener('click', () => openColorPicker('background', bgColor));
        document.getElementById('graph-grid-btn').addEventListener('click', () => openColorPicker('grid', gridColor));

        function loadNodeFields(node) {
            if (!node) return;
            if (!languages.includes(currentEditLang)) {
                currentEditLang = languages[0] || 'ru';
                updateLangButtons();
            }
            Object.keys(fieldElements).forEach(id => {
                const el = fieldElements[id];
                if (!el) return;
                if (id === 'active' || id === 'alwaysActive' || id === 'caseSensitive' || id === 'wholeWords' || id === 'oocInstruction') {
                    el.checked = node[id] !== undefined ? node[id] : (id === 'active' ? true : false);
                } else if (id === 'card_color') {
                    el.style.background = node.color || '#cba6f7';
                } else if (id === 'label') {
                    const langData = getNodeLang(node, currentEditLang);
                    el.value = langData.label || '';
                    const colorBtn = document.getElementById('prop-label-color');
                    if (colorBtn) colorBtn.style.background = node.textColor || '#000000';
                } else if (id === 'desc') {
                    const langData = getNodeLang(node, currentEditLang);
                    el.value = langData.description || '';
                } else if (id === 'comment') {
                    const langData = getNodeLang(node, currentEditLang);
                    el.value = langData.comment || '';
                } else if (id === 'category') {
                    const langData = getNodeLang(node, currentEditLang);
                    el.value = langData.category || '';
                } else if (id === 'keywords') {
                    el.value = (node.keywords || []).join(', ');
                } else if (id === 'additional_keys') {
                    el.value = (node.additionalKeys || []).join(', ');
                } else if (id === 'condition') {
                    el.value = node.condition ?? 'AND_ANY';
                } else if (id === 'groups') {
                    el.value = (node.groups || []).join(', ');
                } else if (id === 'weight') {
                    el.value = node.weight ?? 100;
                } else if (id === 'scan_depth') {
                    el.value = node.scanDepth ?? 0;
                } else if (id === 'recursion_depth') {
                    el.value = node.recursionDepth ?? 0;
                } else if (id === 'priority') {
                    el.value = node.priority ?? 1;
                } else if (id === 'chance') {
                    el.value = node.chance ?? 100;
                } else if (id === 'insertTarget') {
                    el.value = node.insertTarget ?? 'llm';
                } else if (id === 'insertPosition') {
                    el.value = node.insertPosition ?? 'start';
                } else if (id === 'id') {
                    el.value = node.id || '';
                }
            });
            const outgoing = getOutgoing(node.id);
            const incoming = getIncoming(node.id);
            document.getElementById('prop-outgoing-list').innerHTML = outgoing.length ?
                outgoing.map(id => `${getNodeLabel(id)} : ${id}`).join('<br>') : '—';
            document.getElementById('prop-incoming-list').innerHTML = incoming.length ?
                incoming.map(id => `${getNodeLabel(id)} : ${id}`).join('<br>') : '—';
            updateImagePreview(node.image || '');
        }

        function saveNodeFields(node) {
            if (!node) return;
            Object.keys(fieldElements).forEach(id => {
                const el = fieldElements[id];
                if (!el) return;
                if (id === 'active' || id === 'alwaysActive' || id === 'caseSensitive' || id === 'wholeWords' || id === 'oocInstruction') {
                    node[id] = el.checked;
                } else if (id === 'label') {
                    const langData = getNodeLang(node, currentEditLang);
                    langData.label = el.value;
                } else if (id === 'desc') {
                    const langData = getNodeLang(node, currentEditLang);
                    langData.description = el.value;
                } else if (id === 'comment') {
                    const langData = getNodeLang(node, currentEditLang);
                    langData.comment = el.value;
                } else if (id === 'category') {
                    const langData = getNodeLang(node, currentEditLang);
                    langData.category = el.value;
                } else if (id === 'keywords') {
                    node.keywords = el.value.split(',').map(s => s.trim()).filter(Boolean);
                } else if (id === 'additional_keys') {
                    node.additionalKeys = el.value.split(',').map(s => s.trim()).filter(Boolean);
                } else if (id === 'condition') {
                    node.condition = el.value;
                } else if (id === 'groups') {
                    node.groups = el.value.split(',').map(s => s.trim()).filter(Boolean);
                } else if (id === 'weight') {
                    let val = parseInt(el.value);
                    if (isNaN(val) || val < 0) val = 100;
                    if (val > 1000) val = 1000;
                    node.weight = val;
                    el.value = val;
                } else if (id === 'scan_depth') {
                    let val = parseInt(el.value);
                    if (isNaN(val) || val < 0) val = 3;
                    if (val > 100) val = 100;
                    node.scanDepth = val;
                    el.value = val;
                } else if (id === 'recursion_depth') {
                    let val = parseInt(el.value);
                    if (isNaN(val) || val < 0) val = 0;
                    if (val > 5) val = 5;
                    node.recursionDepth = val;
                    el.value = val;
                } else if (id === 'priority') {
                    let val = parseInt(el.value);
                    if (isNaN(val)) val = 10;
                    if (val > 1000) val = 1000;
                    node.priority = val;
                    el.value = val;
                } else if (id === 'chance') {
                    let val = parseInt(el.value);
                    if (isNaN(val) || val < 0) val = 100;
                    if (val > 199) val = 199;
                    node.chance = val;
                    el.value = val;
                } else if (id === 'insertTarget') {
                    node.insertTarget = el.value;
                } else if (id === 'insertPosition') {
                    node.insertPosition = el.value;
                }
            });
            const colorBtn = document.getElementById('prop-label-color');
            if (colorBtn) node.textColor = colorBtn.style.background;
            saveGraphData();
            requestRedraw();
        }

        fieldsContainer.addEventListener('change', function(e) {
            const target = e.target;
            if (target.closest('.lore-lang-btn')) return;
            const nodeId = document.getElementById('prop-id').value;
            if (!nodeId) return;
            const node = nodes.find(n => n.id === nodeId);
            if (!node) return;
            saveNodeFields(node);
        });

        function showNodeInfo(node) {
            const empty = document.getElementById('property-empty');
            const content = document.getElementById('property-content');
            if (!node) {
                empty.style.display = 'block';
                content.style.display = 'none';
                Object.keys(fieldElements).forEach(id => {
                    const el = fieldElements[id];
                    if (!el) return;
                    if (el.type === 'checkbox') {
                        el.checked = false;
                    } else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') {
                        el.value = '';
                    }
                });
                return;
            }
            empty.style.display = 'none';
            content.style.display = 'block';
            const idField = document.getElementById('prop-id');
            if (idField) idField.value = node.id;
            loadNodeFields(node);
            updateLangButtons();
            hideCategoryDropdown();
            hideGroupsDropdown();

            const condSelect = document.getElementById('prop-condition');
            if (condSelect) {
                const parent = condSelect.parentNode;
                const newSelect = document.createElement('select');
                newSelect.className = 'lore-input';
                newSelect.id = 'prop-condition';
                const options = [
                    { value: 'AND_ANY', label: L10N.t('condition_and_any') },
                    { value: 'AND_ALL', label: L10N.t('condition_and_all') },
                    { value: 'NOT_ALL', label: L10N.t('condition_not_all') },
                    { value: 'NOT_ANY', label: L10N.t('condition_not_any') }
                ];
                const selectedValue = node.condition || 'AND_ANY';
                options.forEach(opt => {
                    const option = document.createElement('option');
                    option.value = opt.value;
                    option.textContent = opt.label;
                    if (opt.value === selectedValue) option.selected = true;
                    newSelect.appendChild(option);
                });
                parent.replaceChild(newSelect, condSelect);
                fieldElements['condition'] = newSelect;
            }

            const targetSelect = document.getElementById('prop-insertTarget');
            if (targetSelect) {
                const parent = targetSelect.parentNode;
                const newSelect = document.createElement('select');
                newSelect.className = 'lore-input';
                newSelect.id = 'prop-insertTarget';
                const options = [
                    { value: 'llm', label: L10N.t('insert_target_llm') },
                    { value: 'last_message', label: L10N.t('insert_target_last_message') },
                 // { value: 'summary', label: L10N.t('insert_target_summary') },
                    { value: 'user_appearance', label: L10N.t('insert_target_user_appearance') },
                    { value: 'prefill', label: L10N.t('insert_target_prefill') }
                ];
                const selectedValue = node.insertTarget || 'llm';
                options.forEach(opt => {
                    const option = document.createElement('option');
                    option.value = opt.value;
                    option.textContent = opt.label;
                    if (opt.value === selectedValue) option.selected = true;
                    newSelect.appendChild(option);
                });
                parent.replaceChild(newSelect, targetSelect);
                fieldElements['insertTarget'] = newSelect;
            }

            const posSelect = document.getElementById('prop-insertPosition');
            if (posSelect) {
                const parent = posSelect.parentNode;
                const newSelect = document.createElement('select');
                newSelect.className = 'lore-input';
                newSelect.id = 'prop-insertPosition';
                const options = [
                    { value: 'start', label: L10N.t('insert_position_start') },
                    { value: 'replace', label: L10N.t('insert_position_replace') },
                    { value: 'end', label: L10N.t('insert_position_end') }
                ];
                const selectedValue = node.insertPosition || 'start';
                options.forEach(opt => {
                    const option = document.createElement('option');
                    option.value = opt.value;
                    option.textContent = opt.label;
                    if (opt.value === selectedValue) option.selected = true;
                    newSelect.appendChild(option);
                });
                parent.replaceChild(newSelect, posSelect);
                fieldElements['insertPosition'] = newSelect;
            }
        }

        function updateImagePreview(imageData) {
            const container = document.getElementById('image-preview-container');
            while (container.firstChild) container.removeChild(container.firstChild);
            if (isBase64Image(imageData)) {
                const img = document.createElement('img');
                img.src = imageData;
                img.style.cssText = 'width:100%;height:100%;object-fit:contain;';
                container.appendChild(img);
                container.style.cursor = 'pointer';
            } else if (isEmoji(imageData)) {
                fitEmojiToContainer(imageData, container);
            } else {
                const span = document.createElement('span');
                span.className = 'lore-image-preview';
                span.textContent = '📷';
                container.appendChild(span);
                container.style.cursor = 'default';
            }
        }

function showFullscreenImage(src) {
    if (!src) return;
    const overlay = document.createElement('div');
    overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.9);display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:10000030;';
    const img = document.createElement('img');
    img.src = src;
    img.style.cssText = 'max-width:100vw;max-height:100vh;object-fit:contain;';
    overlay.appendChild(img);
    overlay.addEventListener('click', () => overlay.remove());
    document.body.appendChild(overlay);
}

        const categoryInput = document.getElementById('prop-category');
        const categoryDropdown = document.getElementById('category-dropdown');
        const categoryList = document.getElementById('category-list');
        let categoryDropdownVisible = false;

document.getElementById('image-preview-container').addEventListener('click', () => {
    const nodeId = document.getElementById('prop-id').value;
    if (!nodeId) return;
    const node = nodes.find(n => n.id === nodeId);
    if (node && isBase64Image(node.image)) {
        showFullscreenImage(node.image);
    }
});

        function buildCategoryList(filter = '') {
            const categories = new Set();
            nodes.forEach(n => {
                if (n.i18n && n.i18n[currentEditLang] && n.i18n[currentEditLang].category) {
                    categories.add(n.i18n[currentEditLang].category.trim());
                }
            });
            const filtered = Array.from(categories).sort().filter(c => c.toLowerCase().includes(filter.toLowerCase()));
            categoryList.innerHTML = '';
            if (filtered.length === 0) {
                categoryList.innerHTML = `<div style="padding:6px 10px;color:#a6adc8;font-size:13px;text-align:center;">${L10N.t('no_category')}</div>`;
            } else {
                filtered.forEach(cat => {
                    const item = document.createElement('div');
                    item.textContent = cat;
                    item.style.cssText = 'padding:6px 10px;cursor:pointer;color:#cdd6f4;font-size:13px;transition:0.1s;';
                    item.onmouseenter = () => item.style.background = '#313244';
                    item.onmouseleave = () => item.style.background = 'transparent';
                    item.onclick = function() {
                        categoryInput.value = cat;
                        categoryInput.focus();
                        hideCategoryDropdown();
                        const nodeId = document.getElementById('prop-id').value;
                        if (nodeId) {
                            const node = nodes.find(n => n.id === nodeId);
                            if (node) {
                                const langData = getNodeLang(node, currentEditLang);
                                langData.category = cat;
                                saveGraphData();
                                showNodeInfo(node);
                                requestRedraw();
                            }
                        }
                    };
                    categoryList.appendChild(item);
                });
            }
        }

        function showCategoryDropdown() { buildCategoryList('');
            categoryDropdown.style.display = 'block';
            categoryDropdownVisible = true; }

        function hideCategoryDropdown() { categoryDropdown.style.display = 'none';
            categoryDropdownVisible = false; }

        categoryInput.addEventListener('focus', showCategoryDropdown);
        categoryInput.addEventListener('input', function() {
            if (categoryDropdownVisible) buildCategoryList(this.value);
            else showCategoryDropdown();
        });
        categoryInput.addEventListener('blur', function() {
            setTimeout(() => {
                hideCategoryDropdown();
                const nodeId = document.getElementById('prop-id').value;
                if (nodeId) {
                    const node = nodes.find(n => n.id === nodeId);
                    if (node) {
                        const langData = getNodeLang(node, currentEditLang);
                        if (langData.category !== categoryInput.value) {
                            langData.category = categoryInput.value;
                            saveGraphData();
                            showNodeInfo(node);
                            requestRedraw();
                        }
                    }
                }
            }, 150);
        });
        document.addEventListener('click', function(e) {
            if (categoryDropdownVisible && !categoryInput.contains(e.target) && !categoryDropdown.contains(e.target)) hideCategoryDropdown();
        });

        const groupsInput = document.getElementById('prop-groups');
        const groupsDropdown = document.getElementById('groups-dropdown');
        const groupsList = document.getElementById('groups-list');
        let groupsDropdownVisible = false;

        function getAllGroups() {
            const all = new Set();
            nodes.forEach(n => {
                if (n.groups && Array.isArray(n.groups)) {
                    n.groups.forEach(g => { if (g.trim()) all.add(g.trim()); });
                }
            });
            return Array.from(all).sort();
        }

        function buildGroupsList(filter = '') {
            let allGroups = getAllGroups();
            let filtered = allGroups;
            if (filter) {
                const lowerFilter = filter.toLowerCase();
                filtered = allGroups.filter(g => g.toLowerCase().includes(lowerFilter));
            }
            groupsList.innerHTML = '';
            if (filtered.length === 0) {
                groupsList.innerHTML = `<div style="padding:6px 10px;color:#a6adc8;font-size:13px;text-align:center;">— Нет групп —</div>`;
            } else {
                filtered.forEach(g => {
                    const item = document.createElement('div');
                    item.textContent = g;
                    item.style.cssText = 'padding:6px 10px;cursor:pointer;color:#cdd6f4;font-size:13px;transition:0.1s;';
                    item.onmouseenter = () => item.style.background = '#313244';
                    item.onmouseleave = () => item.style.background = 'transparent';
                    item.onclick = function() {
                        const current = groupsInput.value;
                        const lastComma = current.lastIndexOf(',');
                        let prefix = lastComma !== -1 ? current.substring(0, lastComma + 1) + ' ' : '';
                        groupsInput.value = prefix + g + ', ';
                        groupsInput.focus();
                        hideGroupsDropdown();
                        groupsInput.dispatchEvent(new Event('change', { bubbles: true }));
                    };
                    groupsList.appendChild(item);
                });
            }
        }

        function showGroupsDropdown(filter = '') { buildGroupsList(filter);
            groupsDropdown.style.display = 'block';
            groupsDropdownVisible = true; }

        function hideGroupsDropdown() { groupsDropdown.style.display = 'none';
            groupsDropdownVisible = false; }

        groupsInput.addEventListener('focus', () => showGroupsDropdown(''));
        groupsInput.addEventListener('input', function() {
            const value = this.value;
            const parts = value.split(',');
            const lastPart = parts[parts.length - 1].trim();
            if (parts.length > 1 && lastPart !== '') {
                showGroupsDropdown(lastPart);
            } else if (parts.length > 1 && lastPart === '') {
                showGroupsDropdown('');
            } else if (parts.length === 1 && value === '') {
                showGroupsDropdown('');
            } else if (value.includes(',')) {
                showGroupsDropdown(lastPart);
            } else {
                hideGroupsDropdown();
            }
        });
        groupsInput.addEventListener('blur', function() {
            setTimeout(() => {
                hideGroupsDropdown();
                const nodeId = document.getElementById('prop-id').value;
                if (nodeId) {
                    const node = nodes.find(n => n.id === nodeId);
                    if (node) {
                        node.groups = groupsInput.value.split(',').map(s => s.trim()).filter(Boolean);
                        saveGraphData();
                    }
                }
            }, 150);
        });
        document.addEventListener('click', function(e) {
            if (groupsDropdownVisible && !groupsInput.contains(e.target) && !groupsDropdown.contains(e.target)) hideGroupsDropdown();
        });

        document.getElementById('graph-import-btn').addEventListener('click', async function() {
            const success = await importLibrary();
            if (success) {
                const newData = await loadData();
                languages = newData.languages || ['ru'];
                nodes = newData.nodes.map(n => ({ ...n, _img: null }));
                edges = newData.edges;
                currentEditLang = newData.selectedLang || languages[0] || 'ru';
                saveGraphData();
                renderLayerList();
                showNodeInfo(null);
                requestRedraw();
            }
        });

        // Экспорт
        document.getElementById('graph-export-btn').addEventListener('click', async () => {
            const currentId = await getCurrentLayerId();
            if (!currentId) return;
            const data = await loadLayer(currentId);
            if (!data) return;
            const { id, enabled, ...exportData } = data;
            const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            const name = data.name || currentId;
            a.download = `${name}.json`;
            a.click();
            URL.revokeObjectURL(url);
        });

        document.getElementById('graph-close-btn').addEventListener('click', () => {
            destroyEditor();
            modal.remove();
        });

        function destroyEditor() {
            window.removeEventListener('resize', resizeCanvas);
            document.removeEventListener('mousemove', doResize);
            document.removeEventListener('mouseup', stopResize);
            document.removeEventListener('keydown', handleKeyDown);
            // Удаляем сенсорные обработчики
            canvas.removeEventListener('touchstart', handleTouchStart);
            canvas.removeEventListener('touchmove', handleTouchMove);
            canvas.removeEventListener('touchend', handleTouchEnd);
            canvas.removeEventListener('touchcancel', handleTouchEnd);
            resizeHandle.removeEventListener('touchstart', handleResizeTouchStart);
            resizeHandle.removeEventListener('touchmove', handleResizeTouchMove);
            resizeHandle.removeEventListener('touchend', handleResizeTouchEnd);
            if (animationFrameId) cancelAnimationFrame(animationFrameId);
            if (tooltipBox) { tooltipBox.remove(); tooltipBox = null; }
            if (zoomControls && zoomControls.parentNode) zoomControls.remove();
            window._graphEditorInstance = null;
        }

        let isResizing = false,
            startX = 0,
            startWidth = 0;
        const startResize = e => {
            isResizing = true;
            startX = e.clientX;
            startWidth = propertiesPanel.offsetWidth;
            resizeHandle._dragging = true;
            resizeHandle.style.background = '#89b4fa';
            document.body.style.cursor = 'col-resize';
            document.body.style.userSelect = 'none';
            e.preventDefault();
        };
        const doResize = e => {
            if (!isResizing) return;
            let newWidth = startWidth + (startX - e.clientX);
            newWidth = Math.min(800, Math.max(300, newWidth));
            propertiesPanel.style.width = newWidth + 'px';
            savePanelWidth(newWidth);
            resizeCanvas();
            e.preventDefault();
        };
        const stopResize = e => {
            if (isResizing) {
                isResizing = false;
                resizeHandle._dragging = false;
                resizeHandle.style.background = 'transparent';
                document.body.style.cursor = '';
                document.body.style.userSelect = '';
                resizeCanvas();
            }
        };
        resizeHandle.addEventListener('mousedown', startResize);
        document.addEventListener('mousemove', doResize);
        document.addEventListener('mouseup', stopResize);

        // Touch-ресайз разделителя
        const handleResizeTouchStart = (e) => {
            e.preventDefault();
            const touch = e.touches[0];
            startX = touch.clientX;
            startWidth = propertiesPanel.offsetWidth;
            isResizing = true;
            resizeHandle._dragging = true;
            resizeHandle.style.background = '#89b4fa';
            document.body.style.cursor = 'col-resize';
            document.body.style.userSelect = 'none';
        };
        const handleResizeTouchMove = (e) => {
            if (!isResizing) return;
            e.preventDefault();
            const touch = e.touches[0];
            let newWidth = startWidth + (startX - touch.clientX);
            newWidth = Math.min(800, Math.max(300, newWidth));
            propertiesPanel.style.width = newWidth + 'px';
            savePanelWidth(newWidth);
            resizeCanvas();
        };
        const handleResizeTouchEnd = (e) => {
            if (isResizing) {
                isResizing = false;
                resizeHandle._dragging = false;
                resizeHandle.style.background = 'transparent';
                document.body.style.cursor = '';
                document.body.style.userSelect = '';
                resizeCanvas();
            }
        };
        resizeHandle.addEventListener('touchstart', handleResizeTouchStart, { passive: false });
        resizeHandle.addEventListener('touchmove', handleResizeTouchMove, { passive: false });
        resizeHandle.addEventListener('touchend', handleResizeTouchEnd);

        const contextMenu = document.createElement('div');
        contextMenu.id = 'graph-context-menu';
        contextMenu.style.cssText = 'position:fixed;display:none;background:#1e1e2e;border:1px solid #45475a;border-radius:8px;padding:6px 0;z-index:10000001;min-width:150px;box-shadow:0 8px 24px rgba(0,0,0,0.9);';
        modal.appendChild(contextMenu);

        const ctx = canvas.getContext('2d');
        let selectedNodeId = null,
            selectedEdgeIds = [],
            selectedFromEdge = false,
            edgeSelectionSource = null;
        let isDragging = false,
            dragOffsetX = 0,
            dragOffsetY = 0;
        let scale = 1,
            offsetX = 0,
            offsetY = 0;
        let isPanning = false,
            panStartX = 0,
            panStartY = 0;
        let selectingTarget = false,
            sourceNodeId = null,
            mouseCanvasX = 0,
            mouseCanvasY = 0,
            hoveredTargetId = null;
        // Сенсорные переменные
        let isTouching = false;
        let lastPinchDist = 0;
        let pinchCenterX = 0, pinchCenterY = 0;
        let longPressTimer = null;
        let longPressStartPos = { x: 0, y: 0 };
        const LONG_PRESS_DURATION = 500;
        const LONG_PRESS_MOVE_THRESHOLD = 5;

        function getNodeAt(pos) {
            for (let i = nodes.length - 1; i >= 0; i--) {
                if (pointInCard(nodes[i], pos.x, pos.y)) return nodes[i];
            }
            return null;
        }

        function getEdgesAt(pos) {
            const threshold = 12 / scale;
            const result = [];
            for (const edge of edges) {
                const from = nodes.find(n => n.id === edge.from);
                const to = nodes.find(n => n.id === edge.to);
                if (!from || !to) continue;
                const points = getEdgePoints(edge, edges, nodes);
                const p1 = points.from,
                    p2 = points.to;
                const dx = p2.x - p1.x,
                    dy = p2.y - p1.y;
                const len2 = dx * dx + dy * dy;
                if (len2 === 0) continue;
                let t = ((pos.x - p1.x) * dx + (pos.y - p1.y) * dy) / len2;
                t = Math.max(0, Math.min(1, t));
                const projX = p1.x + t * dx,
                    projY = p1.y + t * dy;
                if ((pos.x - projX) ** 2 + (pos.y - projY) ** 2 < threshold * threshold) result.push(edge);
            }
            return result;
        }

        function drawGrid() {
            const step = 50;
            ctx.save();
            ctx.translate(offsetX, offsetY);
            ctx.scale(scale, scale);
            const hex = gridColor.replace('#', '');
            const r = parseInt(hex.substring(0, 2), 16) || 0,
                g = parseInt(hex.substring(2, 4), 16) || 0,
                b = parseInt(hex.substring(4, 6), 16) || 0;
            ctx.strokeStyle = `rgba(${r},${g},${b},0.15)`;
            ctx.lineWidth = 1 / scale;
            const rect = canvas.getBoundingClientRect();
            const left = (0 - offsetX) / scale,
                top = (0 - offsetY) / scale;
            const right = (rect.width - offsetX) / scale,
                bottom = (rect.height - offsetY) / scale;
            const startX = Math.floor(left / step) * step - step,
                startY = Math.floor(top / step) * step - step;
            const endX = Math.ceil(right / step) * step + step,
                endY = Math.ceil(bottom / step) * step + step;
            ctx.beginPath();
            for (let x = startX; x <= endX; x += step) { ctx.moveTo(x, startY);
                ctx.lineTo(x, endY); }
            for (let y = startY; y <= endY; y += step) { ctx.moveTo(startX, y);
                ctx.lineTo(endX, y); }
            ctx.stroke();
            ctx.restore();
        }

        function draw() {
            ctx.fillStyle = bgColor;
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            drawGrid();
            ctx.save();
            ctx.translate(offsetX, offsetY);
            ctx.scale(scale, scale);
            for (const edge of edges) {
                const from = nodes.find(n => n.id === edge.from);
                const to = nodes.find(n => n.id === edge.to);
                if (!from || !to) continue;
                const points = getEdgePoints(edge, edges, nodes);
                const isSelected = selectedEdgeIds.includes(edge.id);
                ctx.beginPath();
                ctx.moveTo(points.from.x, points.from.y);
                ctx.lineTo(points.to.x, points.to.y);
                ctx.strokeStyle = isSelected ? '#f9e2af' : '#cba6f7';
                ctx.lineWidth = isSelected ? 4 : 3;
                ctx.stroke();
                const angle = Math.atan2(points.to.y - points.from.y, points.to.x - points.from.x);
                drawArrow(ctx, points.to.x, points.to.y, angle, 15, isSelected ? '#f9e2af' : '#cba6f7');
            }
            for (const node of nodes) {
                if (hoveredTargetId !== null && node.id === hoveredTargetId) continue;
                drawNode(ctx, node, false);
            }
            if (hoveredTargetId !== null) {
                const targetNode = nodes.find(n => n.id === hoveredTargetId);
                if (targetNode) drawNode(ctx, targetNode, true);
            }
            if (selectingTarget && sourceNodeId !== null) {
                const source = nodes.find(n => n.id === sourceNodeId);
                if (source) {
                    const pFrom = intersectCardWithLine(source, { x: mouseCanvasX, y: mouseCanvasY });
                    ctx.beginPath();
                    ctx.moveTo(pFrom.x, pFrom.y);
                    ctx.lineTo(mouseCanvasX, mouseCanvasY);
                    ctx.strokeStyle = '#f9e2af';
                    ctx.lineWidth = 3 / scale;
                    ctx.setLineDash([6 / scale, 4 / scale]);
                    ctx.stroke();
                    ctx.setLineDash([]);
                    const angle = Math.atan2(mouseCanvasY - pFrom.y, mouseCanvasX - pFrom.x);
                    const arrowSize = 8 / scale;
                    ctx.beginPath();
                    ctx.moveTo(mouseCanvasX, mouseCanvasY);
                    ctx.lineTo(mouseCanvasX - arrowSize * Math.cos(angle - 0.5), mouseCanvasY - arrowSize * Math.sin(angle - 0.5));
                    ctx.moveTo(mouseCanvasX, mouseCanvasY);
                    ctx.lineTo(mouseCanvasX - arrowSize * Math.cos(angle + 0.5), mouseCanvasY - arrowSize * Math.sin(angle + 0.5));
                    ctx.strokeStyle = '#f9e2af';
                    ctx.lineWidth = 2 / scale;
                    ctx.stroke();
                    const tipText = L10N.t('tip_select_target');
                    const tipX = mouseCanvasX + 15,
                        tipY = mouseCanvasY - 15;
                    ctx.fillStyle = 'rgba(0,0,0,0.7)';
                    ctx.fillRect(tipX - 4, tipY - 18, ctx.measureText(tipText).width + 8, 24);
                    ctx.fillStyle = '#f9e2af';
                    ctx.font = '12px sans-serif';
                    ctx.textAlign = 'left';
                    ctx.textBaseline = 'bottom';
                    ctx.fillText(tipText, tipX, tipY);
                }
            }
            ctx.restore();
        }

        function drawNode(ctx, node, highlight) {
            const isNodeSelected = (selectedNodeId === node.id) ||
                (edgeSelectionSource === 'edge' && selectedEdgeIds.some(eid => {
                    const e = edges.find(ed => ed.id === eid);
                    return e && (e.from === node.id || e.to === node.id);
                }));
            const isHighlight = highlight && !isNodeSelected;
            const cx = node.x,
                cy = node.y;
            const left = cx - CARD_W / 2,
                top = cy - CARD_H / 2;

            ctx.save();
            ctx.shadowColor = 'rgba(0,0,0,0.5)';
            ctx.shadowBlur = 10;
            drawRoundedRect(ctx, left, top, CARD_W, CARD_H, CARD_RADIUS);
            ctx.fillStyle = node.color || '#cba6f7';
            ctx.fill();
            ctx.strokeStyle = isNodeSelected ? '#f9e2af' : (isHighlight ? '#a6e3a1' : '#45475a');
            ctx.lineWidth = isNodeSelected ? 8 : (isHighlight ? 8 : 2);
            ctx.stroke();
            ctx.shadowBlur = 0;
            if (isHighlight) {
                drawRoundedRect(ctx, left - 4, top - 4, CARD_W + 8, CARD_H + 8, CARD_RADIUS + 2);
                ctx.strokeStyle = 'rgba(166,227,161,0.6)';
                ctx.lineWidth = 2;
                ctx.setLineDash([4, 4]);
                ctx.stroke();
                ctx.setLineDash([]);
            }
            const imgAreaX = left + 5,
                imgAreaY = top + 5,
                imgSize = IMG_SIZE;
            if (isBase64Image(node.image)) {
                if (!node._img) {
                    const img = new Image();
                    img.onload = function() { node._img = img;
                        requestRedraw(); };
                    img.onerror = function() { node._img = null;
                        requestRedraw(); };
                    img.src = node.image;
                    node._img = img;
                }
                if (node._img && node._img.complete && node._img.naturalWidth > 0) {
                    const img = node._img;
                    const scaleImg = Math.min(imgSize / img.naturalWidth, imgSize / img.naturalHeight);
                    const drawW = img.naturalWidth * scaleImg,
                        drawH = img.naturalHeight * scaleImg;
                    const offX = (imgSize - drawW) / 2,
                        offY = (imgSize - drawH) / 2;
                    ctx.drawImage(img, imgAreaX + offX, imgAreaY + offY, drawW, drawH);
                } else {
                    ctx.fillStyle = '#cdd6f4';
                    ctx.font = '40px sans-serif';
                    ctx.textAlign = 'center';
                    ctx.textBaseline = 'middle';
                    ctx.fillText('📇', cx, imgAreaY + imgSize / 2);
                }
            } else if (isEmoji(node.image)) {
                const { width, height } = measureEmoji(node.image, 100);
                const scaleEmoji = Math.min(imgSize / width, imgSize / height);
                const fontSize = Math.floor(100 * scaleEmoji);
                ctx.font = fontSize + 'px sans-serif';
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillStyle = '#cdd6f4';
                ctx.fillText(node.image, cx, imgAreaY + imgSize / 2);
            } else {
                ctx.fillStyle = '#cdd6f4';
                ctx.font = '40px sans-serif';
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillText('📇', cx, imgAreaY + imgSize / 2);
            }
            const langData = getNodeLang(node, currentEditLang);
            const label = langData.label || '?';
            ctx.fillStyle = node.textColor || '#000000';
            ctx.font = 'bold 14px Arial';
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            const maxWidth = CARD_W - 8;
            const words = label.split(' ');
            let lines = [],
                currentLine = '';
            for (let w of words) {
                const testLine = currentLine ? currentLine + ' ' + w : w;
                if (ctx.measureText(testLine).width > maxWidth && currentLine) {
                    lines.push(currentLine);
                    currentLine = w;
                } else currentLine = testLine;
            }
            if (currentLine) lines.push(currentLine);
            if (lines.length > 2) { lines = lines.slice(0, 2);
                lines[1] += '…'; }
            const textTop = top + IMG_AREA - 3,
                lineHeight = 14;
            const startY = textTop + (TEXT_H - lines.length * lineHeight) / 2 + lineHeight / 2;
            for (let i = 0; i < lines.length; i++) {
                ctx.fillText(lines[i], cx, startY + i * lineHeight);
            }
            ctx.restore();
        }

        function drawArrow(ctx, x, y, angle, size, color) {
            ctx.save();
            ctx.translate(x, y);
            ctx.rotate(angle);
            ctx.beginPath();
            ctx.moveTo(0, 0);
            ctx.lineTo(-size, -size / 2);
            ctx.lineTo(-size, size / 2);
            ctx.closePath();
            ctx.fillStyle = color || '#cba6f7';
            ctx.fill();
            ctx.restore();
        }

        function getCanvasCoords(e) {
            const rect = canvas.getBoundingClientRect();
            return { x: (e.clientX - rect.left - offsetX) / scale, y: (e.clientY - rect.top - offsetY) / scale };
        }

        // Сенсорные обработчики
        function handleTouchStart(e) {
            e.preventDefault();
            // Закрываем контекстное меню, если оно открыто (любое касание его скрывает)
            if (contextMenu.style.display === 'block') {
                contextMenu.style.display = 'none';
                clearLongPressTimer();
                return;
            }

            if (e.touches.length === 1) {
                const touch = e.touches[0];
                // Долгое нажатие
                clearLongPressTimer();
                longPressStartPos = { x: touch.clientX, y: touch.clientY };
                longPressTimer = setTimeout(() => {
                    // Эмулируем правый клик
                    const fakeEvent = { clientX: longPressStartPos.x, clientY: longPressStartPos.y, preventDefault: () => {} };
                    handleContextMenu(fakeEvent);
                    clearLongPressTimer();
                }, LONG_PRESS_DURATION);
                const fakeEvent = { clientX: touch.clientX, clientY: touch.clientY };
                handleMouseDown(fakeEvent);
                isTouching = true;
            } else if (e.touches.length === 2) {
                clearLongPressTimer();
                // Начало пинча – сбрасываем панорамирование и перетаскивание, скрываем меню
                contextMenu.style.display = 'none';
                isPanning = false;
                isDragging = false;
                const t1 = e.touches[0];
                const t2 = e.touches[1];
                lastPinchDist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
                pinchCenterX = (t1.clientX + t2.clientX) / 2;
                pinchCenterY = (t1.clientY + t2.clientY) / 2;
                isTouching = true;
            }
        }

        function handleTouchMove(e) {
            e.preventDefault();
            if (e.touches.length === 1 && isTouching) {
                const touch = e.touches[0];
                // Проверяем движение для отмены долгого нажатия
                if (longPressTimer && (Math.abs(touch.clientX - longPressStartPos.x) > LONG_PRESS_MOVE_THRESHOLD || Math.abs(touch.clientY - longPressStartPos.y) > LONG_PRESS_MOVE_THRESHOLD)) {
                    clearLongPressTimer();
                }
                const fakeEvent = { clientX: touch.clientX, clientY: touch.clientY };
                handleMouseMove(fakeEvent);
            } else if (e.touches.length === 2 && isTouching) {
                clearLongPressTimer();
                // Пинч-зум
                const t1 = e.touches[0];
                const t2 = e.touches[1];
                const newDist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
                if (lastPinchDist > 0 && newDist > 0) {
                    const delta = newDist / lastPinchDist;
                    const newScale = Math.min(Math.max(scale * delta, 0.04), 5);
                    const rect = canvas.getBoundingClientRect();
                    const mx = pinchCenterX - rect.left;
                    const my = pinchCenterY - rect.top;
                    const wx = (mx - offsetX) / scale;
                    const wy = (my - offsetY) / scale;
                    offsetX = mx - wx * newScale;
                    offsetY = my - wy * newScale;
                    scale = newScale;
                    requestRedraw();
                }
                lastPinchDist = newDist;
                pinchCenterX = (t1.clientX + t2.clientX) / 2;
                pinchCenterY = (t1.clientY + t2.clientY) / 2;
            }
        }

        function handleTouchEnd(e) {
            clearLongPressTimer();
            if (!isTouching) return;
            const fakeEvent = { clientX: 0, clientY: 0 };
            handleMouseUp(fakeEvent);
            isTouching = false;
            lastPinchDist = 0;
        }

        function clearLongPressTimer() {
            if (longPressTimer) {
                clearTimeout(longPressTimer);
                longPressTimer = null;
            }
        }

        function openColorPicker(target, currentColor) {
            const titles = {
                'text': L10N.t('color_picker_title_text'),
                'card': L10N.t('color_picker_title_card'),
                'background': L10N.t('color_picker_title_background'),
                'grid': L10N.t('color_picker_title_grid')
            };
            const overlay = document.createElement('div');
            overlay.className = 'lore-modal-overlay';
            overlay.style.zIndex = '10000009';
            const box = document.createElement('div');
            box.className = 'lore-modal-box';
            box.innerHTML = `
                <h3 class="lore-modal-title">${titles[target] || 'Выберите цвет'}</h3>
                <div class="lore-color-grid" id="color-grid"></div>
                <div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
                    <input type="text" maxlength="7" placeholder="#000000" value="${currentColor || '#000000'}" class="lore-input" style="flex:1;text-transform:uppercase;" id="hex-input">
                    <button class="lore-btn lore-btn-success" id="apply-hex">${L10N.t('color_picker_ok')}</button>
                </div>
                <button class="lore-btn lore-btn-danger" style="align-self:center;margin-top:4px;" id="cancel-color">${L10N.t('color_picker_cancel')}</button>
            `;
            overlay.appendChild(box);
            document.body.appendChild(overlay);
            const grid = box.querySelector('#color-grid');
            COLORS.forEach(color => {
                const swatch = document.createElement('div');
                swatch.className = 'lore-color-swatch-small';
                swatch.style.background = color;
                swatch.onclick = () => { applyColor(target, color);
                    overlay.remove(); };
                grid.appendChild(swatch);
            });
            const hexInput = box.querySelector('#hex-input');
            const applyHex = box.querySelector('#apply-hex');
            applyHex.onclick = () => {
                let color = hexInput.value.trim();
                if (color.startsWith('#')) {
                    if (/^#[0-9a-f]{6}$/i.test(color)) { applyColor(target, color);
                        overlay.remove(); return; }
                } else if (/^[0-9a-f]{6}$/i.test(color)) {
                    color = '#' + color;
                    applyColor(target, color);
                    overlay.remove();
                    return;
                }
                showDialog({ title: L10N.t('color_picker_error_title'), message: L10N.t('color_picker_error_msg'), type: 'alert', zIndex: 10000009 });
            };
            hexInput.addEventListener('keydown', e => { if (e.key === 'Enter') applyHex.click(); });
            box.querySelector('#cancel-color').onclick = () => overlay.remove();
            overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
        }

        function applyColor(target, color) {
            const nodeId = document.getElementById('prop-id').value;
            const node = nodeId ? nodes.find(n => n.id === nodeId) : null;
            if (target === 'text' && node) {
                node.textColor = color;
                const btn = document.getElementById('prop-label-color');
                if (btn) btn.style.background = color;
                saveGraphData();
                requestRedraw();
            } else if (target === 'card' && node) {
                node.color = color;
                const btn = document.getElementById('prop-card_color');
                if (btn) btn.style.background = color;
                saveGraphData();
                requestRedraw();
            } else if (target === 'background') {
                bgColor = color;
                saveBgColor(color);
                document.querySelector('#graph-bg-btn .lore-color-swatch').style.background = color;
            } else if (target === 'grid') {
                gridColor = color;
                saveGridColor(color);
                document.querySelector('#graph-grid-btn .lore-color-swatch').style.background = color;
            }
            requestRedraw();
        }

        document.getElementById('btn-insert-base64').addEventListener('click', insertBase64);
        document.getElementById('btn-choose-emoji').addEventListener('click', chooseEmoji);
        document.getElementById('btn-clear-image').addEventListener('click', clearImage);

        async function insertBase64() {
            const nodeId = document.getElementById('prop-id').value;
            if (!nodeId) return;
            const node = nodes.find(n => n.id === nodeId);
            if (!node) return;
            const current = node.image && isBase64Image(node.image) ? node.image : '';
            const input = await showDialog({
                title: L10N.t('dialog_insert_base64_title'),
                message: L10N.t('dialog_insert_base64_msg'),
                type: 'prompt',
                defaultValue: current,
                placeholder: L10N.t('dialog_insert_base64_placeholder'),
                zIndex: 10000009
            });
            if (input === null) return;
            const trimmed = input.trim();
            if (!trimmed) return;
            if (!trimmed.startsWith('data:image/')) {
                showDialog({ title: L10N.t('dialog_base64_error_title'), message: L10N.t('dialog_base64_error_msg'), type: 'alert', zIndex: 10000009 });
                return;
            }
            if (trimmed.length > MAX_IMAGE_SIZE) {
                showDialog({ title: L10N.t('dialog_base64_error_title'), message: L10N.t('dialog_base64_size_error'), type: 'alert', zIndex: 10000009 });
                return;
            }
            node.image = trimmed;
            node._img = null;
            saveGraphData();
            updateImagePreview(trimmed);
            requestRedraw();
        }

        async function clearImage() {
            const nodeId = document.getElementById('prop-id').value;
            if (!nodeId) return;
            const node = nodes.find(n => n.id === nodeId);
            if (!node) return;
            if (!node.image) {
                showDialog({ title: L10N.t('color_picker_error_title'), message: L10N.t('dialog_clear_image_info'), type: 'alert', zIndex: 10000009 });
                return;
            }
            const confirmed = await showDialog({
                title: L10N.t('dialog_clear_image_confirm'),
                message: L10N.t('dialog_clear_image_confirm'),
                type: 'confirm',
                zIndex: 10000009
            });
            if (!confirmed) return;
            node.image = '';
            node._img = null;
            saveGraphData();
            updateImagePreview('');
            requestRedraw();
        }

        const EMOJI_DATA = {
            emotions: { list: ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '😚', '😙', '😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴', '😵', '🤯', '🤠', '🥳', '😎', '🤓', '🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲', '😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢', '😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀', '☠️', '👻', '👽', '👾', '🤖', '💩', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾'] },
            people: { list: ['👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪', '🦾', '🦵', '🦿', '🦶', '👂', '🦻', '👃', '🧠', '🦷', '🦴', '👀', '👁️', '👅', '👄', '💋', '🩸', '👶', '🧒', '👦', '👧', '🧑', '👱', '👨', '👩', '🧓', '👴', '👵', '👨‍⚕️', '👩‍⚕️', '👨‍🎓', '👩‍🎓', '👨‍🏫', '👩‍🏫', '👨‍⚖️', '👩‍⚖️', '👨‍🌾', '👩‍🌾', '👨‍🍳', '👩‍🍳', '👨‍🔧', '👩‍🔧', '👨‍🏭', '👩‍🏭', '👨‍💼', '👩‍💼', '👨‍🔬', '👩‍🔬', '👨‍💻', '👩‍💻', '👨‍🎤', '👩‍🎤', '👨‍🎨', '👩‍🎨', '👨‍✈️', '👩‍✈️', '👨‍🚀', '👩‍🚀', '👨‍🚒', '👩‍🚒', '👮', '👷', '💂', '🕵️', '👨‍🦰', '👩‍🦰', '👨‍🦱', '👩‍🦱', '👨‍🦳', '👩‍🦳', '👨‍🦲', '👩‍🦲', '🧔', '💆', '💇', '🧖', '🧘', '🛀', '🛌', '🧍', '🧎', '🧑‍🤝‍🧑', '👭', '👫', '👬', '💏', '💑', '👪', '👨‍👩‍👦', '👨‍👩‍👧', '👨‍👩‍👧‍👦', '👨‍👩‍👦‍👦', '👨‍👩‍👧‍👧', '👨‍👦', '👨‍👦‍👦', '👨‍👧', '👨‍👧‍👦', '👨‍👧‍👧', '👩‍👦', '👩‍👦‍👦', '👩‍👧', '👩‍👧‍👦', '👩‍👧‍👧', '🧙', '🧚', '🧛', '🧟', '🧞', '🧜', '🧝', '🦸', '🦹', '👯', '🕴️', '🗣️', '👤', '👥'] },
            animals: { list: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐽', '🐸', '🐵', '🙈', '🙉', '🙊', '🐒', '🐔', '🐧', '🐦', '🐤', '🐣', '🐥', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗', '🐴', '🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐜', '🦟', '🦗', '🕷️', '🕸️', '🦂', '🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑', '🦐', '🦀', '🐡', '🐠', '🐟', '🐬', '🐳', '🐋', '🦈', '🐊', '🐅', '🐆', '🦓', '🦍', '🐘', '🦏', '🦛', '🐪', '🐫', '🦒', '🐃', '🐂', '🐄', '🐎', '🐖', '🐏', '🐑', '🐐', '🦌', '🐕', '🐩', '🐈', '🐓', '🦃', '🦚', '🦜', '🦢', '🦩', '🕊️', '🐇', '🦝', '🦨', '🦡', '🦔', '🐾', '🌸', '💮', '🏵️', '🌹', '🥀', '🌺', '🌻', '🌼', '🌷', '🌱', '🌲', '🌳', '🌴', '🌵', '🌾', '🌿', '☘️', '🍀', '🍁', '🍂', '🍃', '🍄', '🌰', '🌍', '🌎', '🌏', '🌕', '🌖', '🌗', '🌘', '🌙', '🌚', '🌛', '🌜', '☀️', '🌝', '🌞', '⭐', '🌟', '🌠', '🌌', '☁️', '⛅', '⛈️', '🌤️', '🌥️', '🌦️', '🌧️', '🌨️', '🌩️', '🌪️', '🌫️', '🌬️', '💨', '💧', '💦', '☔', '☂️', '🌊'] },
            food: { list: ['🍎', '🍏', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🍆', '🥑', '🥦', '🥬', '🥒', '🌶️', '🌽', '🥕', '🧄', '🧅', '🥔', '🍠', '🥐', '🥖', '🍞', '🥨', '🥯', '🧀', '🥚', '🍳', '🧈', '🥞', '🧇', '🥓', '🥩', '🍗', '🍖', '🦴', '🌭', '🍔', '🍟', '🍕', '🥪', '🥙', '🧆', '🌮', '🌯', '🥗', '🥘', '🥫', '🍝', '🍜', '🍲', '🍛', '🍣', '🍱', '🥟', '🦪', '🍤', '🍙', '🍚', '🍘', '🍥', '🥠', '🥮', '🍢', '🍡', '🍧', '🍨', '🍦', '🥧', '🧁', '🍰', '🎂', '🍮', '🍭', '🍬', '🍫', '🍿', '🍩', '🍪', '🌰', '🥜', '🍯', '🥛', '🍼', '☕', '🍵', '🧃', '🥤', '🧉', '🍶', '🍺', '🍻', '🥂', '🍷', '🥃', '🍸', '🍹', '🧊', '🥄', '🍴', '🍽️', '🥣', '🥡', '🥢', '🧂'] },
            travel: { list: ['🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑', '🚒', '🚐', '🚛', '🚜', '🦯', '🦽', '🦼', '🛴', '🚲', '🛵', '🏍️', '🛺', '🚨', '🚔', '🚍', '🚘', '🚖', '🚡', '🚠', '🚟', '🚃', '🚋', '🚞', '🚝', '🚄', '🚅', '🚈', '🚂', '🚆', '🚇', '🚊', '🚉', '✈️', '🛫', '🛬', '🛩️', '💺', '🛰️', '🚀', '🛸', '🚁', '🛶', '⛵', '🚤', '🛥️', '🛳️', '⛴️', '🚢', '⚓', '⛽', '🚧', '🚦', '🚥', '🚏', '🗺️', '🗿', '🗽', '🗼', '🏰', '🏯', '🏟️', '🏛️', '🏗️', '🏘️', '🏚️', '🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨', '🏩', '🏪', '🏫', '🏬', '🏭', '💒', '⛪', '🕌', '🛕', '🕍', '⛩️', '🕋', '⛲', '⛺', '🌁', '🌃', '🏙️', '🌄', '🌅', '🌆', '🌇', '🌉', '♨️', '💈'] },
            sports: { list: ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍', '🏏', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿', '🥊', '🥋', '🎽', '🛹', '🛷', '⛸️', '🥌', '🎿', '⛷️', '🏂', '🪂', '🏋️', '🤼', '🤸', '⛹️', '🤾', '🏌️', '🏇', '🧘', '🏄', '🏊', '🤽', '🚣', '🧗', '🚵', '🚴', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖️', '🏵️', '🎮', '🕹️', '🎰', '🎲', '🧩', '🧸', '♟️', '🎯', '🎳', '🎭', '🎨', '🎬', '🎤', '🎧', '🎼', '🎹', '🥁', '🎷', '🎺', '🎸', '🪕', '🎻', '🎃', '🎄', '🎆', '🎇', '🧨', '✨', '🎈', '🎉', '🎊', '🎋', '🎍', '🎎', '🎏', '🎐', '🎑', '🧧', '🎀', '🎁', '🎗️', '🎟️', '🎫', '🎪', '🎠', '🎡', '🎢'] },
            objects: { list: ['⌚', '📱', '📲', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️', '🗜️', '💽', '💾', '💿', '📀', '📼', '📷', '📸', '📹', '🎥', '📽️', '🎞️', '📞', '☎️', '📟', '📠', '📺', '📻', '🎙️', '🎚️', '🎛️', '🧭', '⏱️', '⏲️', '⏰', '🕰️', '⌛', '⏳', '📡', '🔋', '🔌', '💡', '🔦', '🕯️', '🪔', '🧯', '🛢️', '💸', '💵', '💴', '💶', '💷', '💰', '💳', '💎', '⚖️', '🧰', '🔧', '🔨', '⚒️', '🛠️', '⛏️', '🔩', '⚙️', '🧱', '⛓️', '🧲', '🔫', '💣', '🪓', '🔪', '🗡️', '⚔️', '🛡️', '🏺', '🔮', '📿', '🧿', '⚗️', '🔭', '🔬', '🕳️', '🩹', '🩺', '💊', '💉', '🧬', '🦠', '🧫', '🧪', '🌡️', '🧹', '🧺', '🧻', '🚽', '🚰', '🚿', '🛁', '🛀', '🧼', '🪒', '🧽', '🧴', '🛎️', '🔑', '🗝️', '🚪', '🪑', '🛋️', '🛏️', '🛌', '🖼️', '🛍️', '🛒', '✉️', '📩', '📨', '📧', '💌', '📥', '📤', '📦', '🏷️', '📪', '📫', '📬', '📭', '📮', '📯', '📜', '📃', '📄', '📑', '🧾', '📊', '📈', '📉', '🗒️', '🗓️', '📆', '📅', '🗑️', '📇', '🗃️', '🗳️', '🗄️', '📋', '📁', '📂', '🗂️', '🗞️', '📰', '📓', '📔', '📒', '📕', '📗', '📘', '📙', '📚', '📖', '🔖', '🧷', '🔗', '📎', '🖇️', '📐', '📏', '🧮', '📌', '📍', '✂️', '🖊️', '🖋️', '✒️', '🖌️', '🖍️', '📝', '✏️', '🔍', '🔎', '🔏', '🔐', '🔒', '🔓', '🧥', '🥼', '🦺', '👔', '👕', '👖', '🧣', '🧤', '👗', '👘', '👙', '🩱', '🩲', '🩳', '👚', '👛', '👜', '👝', '🧳', '👓', '🕶️', '🥽', '👒', '🎩', '🎓', '🧢', '⛑️', '💄', '👠', '👡', '👢', '👞', '👟', '🥾', '🥿', '👑', '🎒', '🧵', '🧶', '👣', '🧦', '🥻'] },
            symbols: { list: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶', '🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐', '㊙️', '㊗️', '🈴', '🈵', '🈹', '🈲', '🅰️', '🅱️', '🆎', '🆑', '🅾️', '🆘', '❌', '⭕', '🛑', '⛔', '📛', '🚫', '💯', '💢', '♨️', '❗', '❕', '❓', '❔', '‼️', '⁉️', '🔅', '🔆', '🔱', '⚜️', '🔰', '♻️', '✅', '☑️', '✔️', '✖️', '➕', '➖', '➗', '➰', '➿', '〽️', '✳️', '✴️', '❇️', '🔃', '🔄', '🔙', '🔚', '🔛', '🔜', '🔝', '🔀', '🔁', '🔂', '▶️', '⏩', '⏭️', '⏯️', '◀️', '⏪', '⏮️', '🔼', '⏫', '🔽', '⏬', '⏸️', '⏹️', '⏺️', '⏏️', '🎦', '📶', '♀️', '♂️', '⚧️', '♾️', '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '🟤', '⚫', '⚪', '🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '🟫', '⬛', '⬜', '◼️', '◻️', '◾', '◽', '▪️', '▫️', '🔶', '🔷', '🔸', '🔹', '🔺', '🔻', '💠', '🔘', '🔳', '🔲', '🏁', '🚩', '🏴', '🏳️', '🏳️‍🌈', '🏴‍☠️'] }
        };
        const EMOJI_CAT_KEYS = ['emotions', 'people', 'animals', 'food', 'travel', 'sports', 'objects', 'symbols'];
        const EMOJI_CAT_TRANS = {
            emotions: 'emoji_cat_emotions',
            people: 'emoji_cat_people',
            animals: 'emoji_cat_animals',
            food: 'emoji_cat_food',
            travel: 'emoji_cat_travel',
            sports: 'emoji_cat_sports',
            objects: 'emoji_cat_objects',
            symbols: 'emoji_cat_symbols'
        };

        async function chooseEmoji() {
            const nodeId = document.getElementById('prop-id').value;
            if (!nodeId) return;
            const node = nodes.find(n => n.id === nodeId);
            if (!node) return;
            const overlay = document.createElement('div');
            overlay.className = 'lore-modal-overlay';
            overlay.style.zIndex = '10000009';
            const box = document.createElement('div');
            box.className = 'lore-modal-box';
            box.style.maxWidth = '500px';
            box.style.maxHeight = '95vh';
            box.style.display = 'flex';
            box.style.flexDirection = 'column';
            box.style.overflow = 'hidden';
            const title = document.createElement('h3');
            title.className = 'lore-modal-title';
            title.textContent = L10N.t('emoji_picker_title');
            box.appendChild(title);
            const gridContainer = document.createElement('div');
            gridContainer.style.cssText = 'flex:1;overflow-y:auto;margin-bottom:10px;padding-right:2px;';
            const grid = document.createElement('div');
            grid.className = 'lore-emoji-grid';
            for (const key of EMOJI_CAT_KEYS) {
                const header = document.createElement('div');
                header.className = 'lore-emoji-header';
                header.textContent = L10N.t(EMOJI_CAT_TRANS[key]);
                grid.appendChild(header);
                EMOJI_DATA[key].list.forEach(emoji => {
                    const span = document.createElement('span');
                    span.className = 'lore-emoji-item';
                    span.textContent = emoji;
                    span.onclick = () => {
                        node.image = emoji;
                        node._img = null;
                        saveGraphData();
                        updateImagePreview(emoji);
                        requestRedraw();
                        overlay.remove();
                    };
                    grid.appendChild(span);
                });
            }
            gridContainer.appendChild(grid);
            box.appendChild(gridContainer);
            const customSection = document.createElement('div');
            customSection.className = 'lore-emoji-custom';
            customSection.innerHTML = `
                <span class="lore-emoji-custom-label">${L10N.t('emoji_custom_label')}</span>
                <input id="custom-emoji-input" type="text" maxlength="20" class="lore-emoji-custom-input">
                <button class="lore-btn lore-btn-success" id="custom-emoji-apply">${L10N.t('emoji_custom_ok')}</button>
                <button class="lore-btn lore-btn-danger" id="cancel-emoji-btn">${L10N.t('emoji_custom_cancel')}</button>
            `;
            box.appendChild(customSection);
            overlay.appendChild(box);
            document.body.appendChild(overlay);
            document.getElementById('custom-emoji-apply').addEventListener('click', () => {
                const input = document.getElementById('custom-emoji-input');
                const val = input.value.trim();
                if (val) {
                    node.image = val;
                    node._img = null;
                    saveGraphData();
                    updateImagePreview(val);
                    requestRedraw();
                    overlay.remove();
                } else {
                    showDialog({ title: L10N.t('color_picker_error_title'), message: L10N.t('emoji_custom_error'), type: 'alert', zIndex: 10000009 });
                }
            });
            document.getElementById('custom-emoji-input').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('custom-emoji-apply').click(); });
            document.getElementById('cancel-emoji-btn').onclick = () => overlay.remove();
            overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
        }

        document.getElementById('prop-delete-node-btn').addEventListener('click', async function() {
            const id = document.getElementById('prop-id').value;
            if (!id) return;
            const confirmed = await showDialog({
                title: L10N.t('dialog_confirm_delete_node'),
                message: '',
                type: 'confirm',
                zIndex: 10000009
            });
            if (!confirmed) return;
            edges = edges.filter(e => e.from !== id && e.to !== id);
            nodes = nodes.filter(n => n.id !== id);
            saveGraphData();
            selectedNodeId = null;
            selectedEdgeIds = [];
            selectedFromEdge = false;
            edgeSelectionSource = null;
            showNodeInfo(null);
            requestRedraw();
        });

        function handleMouseDown(e) {
            const nodeId = document.getElementById('prop-id').value;
            if (nodeId) {
                const node = nodes.find(n => n.id === nodeId);
                if (node) saveNodeFields(node);
            }
            const pos = getCanvasCoords(e);
            const node = getNodeAt(pos);
            const foundEdges = getEdgesAt(pos);

            if (selectingTarget) {
                if (node && node.id !== sourceNodeId) {
                    showCreateEdgeDialog(sourceNodeId, node.id);
                    selectingTarget = false;
                    sourceNodeId = null;
                    hoveredTargetId = null;
                    canvas.style.cursor = 'default';
                    requestRedraw();
                    return;
                }
                if (!node) {
                    selectingTarget = false;
                    sourceNodeId = null;
                    hoveredTargetId = null;
                    canvas.style.cursor = 'default';
                    requestRedraw();
                }
                return;
            }

            if (node) {
                selectedNodeId = node.id;
                selectedEdgeIds = edges.filter(e => e.from === node.id || e.to === node.id).map(e => e.id);
                selectedFromEdge = false;
                edgeSelectionSource = 'node';
                isDragging = true;
                dragOffsetX = pos.x - node.x;
                dragOffsetY = pos.y - node.y;
                showNodeInfo(node);
                requestRedraw();
                return;
            }

            if (foundEdges.length > 0) {
                const pair = foundEdges[0];
                selectedEdgeIds = edges.filter(e => (e.from === pair.from && e.to === pair.to) || (e.from === pair.to && e.to === pair.from)).map(e => e.id);
                selectedNodeId = null;
                selectedFromEdge = true;
                edgeSelectionSource = 'edge';
                showNodeInfo(null);
                requestRedraw();
                return;
            }

            isPanning = true;
            panStartX = e.clientX;
            panStartY = e.clientY;
            selectedNodeId = null;
            selectedEdgeIds = [];
            selectedFromEdge = false;
            edgeSelectionSource = null;
            showNodeInfo(null);
            requestRedraw();
        }

        function handleMouseMove(e) {
            const pos = getCanvasCoords(e);
            mouseCanvasX = pos.x;
            mouseCanvasY = pos.y;

            if (selectingTarget) {
                const node = getNodeAt(pos);
                if (node && node.id !== sourceNodeId) {
                    hoveredTargetId = node.id;
                    canvas.style.cursor = 'pointer';
                } else {
                    hoveredTargetId = null;
                    canvas.style.cursor = 'crosshair';
                }
                requestRedraw();
                return;
            }

            if (isDragging && selectedNodeId) {
                const node = nodes.find(n => n.id === selectedNodeId);
                if (node) {
                    node.x = pos.x - dragOffsetX;
                    node.y = pos.y - dragOffsetY;
                    requestRedraw();
                }
            } else if (isPanning) {
                offsetX += e.clientX - panStartX;
                offsetY += e.clientY - panStartY;
                panStartX = e.clientX;
                panStartY = e.clientY;
                requestRedraw();
            } else {
                canvas.style.cursor = (getNodeAt(pos) || getEdgesAt(pos).length > 0) ? 'pointer' : 'default';
            }
        }

        function handleMouseUp(e) {
            if (isDragging || isPanning) saveGraphData();
            isDragging = false;
            isPanning = false;
        }

        async function handleContextMenu(e) {
            e.preventDefault();
            const pos = getCanvasCoords(e);
            const node = getNodeAt(pos);
            const foundEdges = getEdgesAt(pos);

            const hasActiveLayer = !!(await getCurrentLayerId());
            if (!hasActiveLayer) {
                contextMenu.innerHTML = '';
                const msg = document.createElement('div');
                msg.className = 'lore-context-item';
                msg.textContent = L10N.t('no_active_layer');
                msg.style.color = '#a6adc8';
                msg.style.cursor = 'default';
                contextMenu.appendChild(msg);
                contextMenu.style.display = 'block';
                contextMenu.style.left = e.clientX + 'px';
                contextMenu.style.top = e.clientY + 'px';
                return;
            }

            contextMenu.innerHTML = '';
            if (node) {
                const item = document.createElement('div');
                item.className = 'lore-context-item';
                item.textContent = L10N.t('context_create_edge');
                item.onclick = function() {
                    sourceNodeId = node.id;
                    selectingTarget = true;
                    hoveredTargetId = null;
                    canvas.style.cursor = 'crosshair';
                    contextMenu.style.display = 'none';
                    requestRedraw();
                };
                contextMenu.appendChild(item);
                contextMenu._target = { type: 'node', id: node.id };
                contextMenu.style.display = 'block';
                contextMenu.style.left = e.clientX + 'px';
                contextMenu.style.top = e.clientY + 'px';
                return;
            }

            if (foundEdges.length > 0) {
                const pair = foundEdges[0];
                const item = document.createElement('div');
                item.className = 'lore-context-item lore-context-item-danger';
                item.textContent = L10N.t('context_delete_edge');
                item.onclick = async function() {
                    const confirmed = await showDialog({
                        title: L10N.t('dialog_confirm_delete_edge'),
                        message: L10N.t('dialog_confirm_delete_edge'),
                        type: 'confirm',
                        zIndex: 10000009
                    });
                    if (!confirmed) return;
                    removeAllEdgesBetween(pair.from, pair.to);
                    selectedEdgeIds = [];
                    selectedFromEdge = false;
                    edgeSelectionSource = null;
                    saveGraphData();
                    requestRedraw();
                    contextMenu.style.display = 'none';
                };
                contextMenu.appendChild(item);
                contextMenu._target = { type: 'edge', pair: pair };
                contextMenu.style.display = 'block';
                contextMenu.style.left = e.clientX + 'px';
                contextMenu.style.top = e.clientY + 'px';
                return;
            }

            const item = document.createElement('div');
            item.className = 'lore-context-item';
            item.textContent = L10N.t('context_create_node');
            item.onclick = async function() {
                const name = await showDialog({
                    title: L10N.t('dialog_node_name_prompt'),
                    message: L10N.t('dialog_node_name_prompt'),
                    type: 'prompt',
                    placeholder: L10N.t('dialog_node_name_placeholder'),
                    zIndex: 10000009
                });
                // Если пользователь отменил или закрыл диалог — не создаём узел
                if (name === null) {
                    contextMenu.style.display = 'none';
                    return;
                }
                const pos = contextMenu._target?.pos || { x: 100, y: 100 };
                const node = createNode(name || '', pos.x, pos.y, languages);
                nodes.push(node);
                saveGraphData();
                showNodeInfo(node);
                requestRedraw();
                contextMenu.style.display = 'none';
            };
            contextMenu.appendChild(item);
            contextMenu._target = { type: 'graph', pos: pos };
            contextMenu.style.display = 'block';
            contextMenu.style.left = e.clientX + 'px';
            contextMenu.style.top = e.clientY + 'px';
        }

        function showCreateEdgeDialog(fromId, toId) {
            const overlay = document.createElement('div');
            overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;justify-content:center;align-items:center;z-index:10000009;';
            const box = document.createElement('div');
            box.className = 'lore-modal-box';
            box.style.minWidth = '250px';
            box.innerHTML = `
                <h3 class="lore-modal-title">${L10N.t('context_create_edge')}</h3>
                <select id="edge-type-select" style="width:100%;padding:8px;background:#11111b;color:#cdd6f4;border:1px solid #45475a;border-radius:4px;margin:8px 0;">
                    <option value="outgoing">${L10N.t('outgoing_title')}</option>
                    <option value="incoming">${L10N.t('incoming_title')}</option>
                    <option value="bidirectional">Двунаправленная</option>
                </select>
                <div class="lore-modal-actions">
                    <button id="create-edge-btn" class="lore-btn lore-btn-success">${L10N.t('dialog_ok')}</button>
                    <button id="cancel-edge-btn" class="lore-btn lore-btn-danger">${L10N.t('dialog_cancel')}</button>
                </div>
            `;
            overlay.appendChild(box);
            document.body.appendChild(overlay);
            const close = () => overlay.remove();
            document.getElementById('create-edge-btn').onclick = () => {
                const type = document.getElementById('edge-type-select').value;
                removeAllEdgesBetween(fromId, toId);
                if (type === 'outgoing') addEdge(fromId, toId);
                else if (type === 'incoming') addEdge(toId, fromId);
                else if (type === 'bidirectional') { addEdge(fromId, toId);
                    addEdge(toId, fromId); }
                saveGraphData();
                requestRedraw();
                close();
            };
            document.getElementById('cancel-edge-btn').onclick = close;
            overlay.onclick = e => { if (e.target === overlay) close(); };
        }

        function handleKeyDown(e) {
            if (e.key === 'Escape' && selectingTarget) {
                selectingTarget = false;
                sourceNodeId = null;
                hoveredTargetId = null;
                canvas.style.cursor = 'default';
                requestRedraw();
                e.preventDefault();
            }
        }

        // Мышь и клавиатура
        canvas.addEventListener('mousedown', handleMouseDown);
        canvas.addEventListener('mousemove', handleMouseMove);
        canvas.addEventListener('mouseup', handleMouseUp);
        canvas.addEventListener('mouseleave', handleMouseUp);
        canvas.addEventListener('contextmenu', handleContextMenu);
        canvas.addEventListener('wheel', e => {
            e.preventDefault();
            const delta = e.deltaY > 0 ? 0.9 : 1.1;
            const newScale = Math.min(Math.max(scale * delta, 0.04), 5);
            const rect = canvas.getBoundingClientRect();
            const mx = e.clientX - rect.left,
                my = e.clientY - rect.top;
            const wx = (mx - offsetX) / scale,
                wy = (my - offsetY) / scale;
            offsetX = mx - wx * newScale;
            offsetY = my - wy * newScale;
            scale = newScale;
            requestRedraw();
        });
        // Сенсорные события
        canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
        canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
        canvas.addEventListener('touchend', handleTouchEnd);
        canvas.addEventListener('touchcancel', handleTouchEnd);

        document.addEventListener('click', () => contextMenu.style.display = 'none');
        document.addEventListener('keydown', handleKeyDown);

        function resizeCanvas() {
            const rect = canvasContainer.getBoundingClientRect();
            canvas.width = rect.width;
            canvas.height = rect.height;
            draw();
        }
        resizeCanvas();
        window.addEventListener('resize', resizeCanvas);

        const saved = await loadData();
        if (saved.nodes && saved.edges) {
            languages = saved.languages || ['ru'];
            nodes = saved.nodes.map(n => ({ ...n, _img: null }));
            edges = saved.edges;
            currentEditLang = saved.selectedLang || languages[0] || 'ru';
            draw();
            document.querySelector('#graph-bg-btn .lore-color-swatch').style.background = bgColor;
            document.querySelector('#graph-grid-btn .lore-color-swatch').style.background = gridColor;
            updateLangButtons();
            updateZoomButtons();
        }

        // Если активный слой не определён, но есть хотя бы один слой, выбираем первый
        const currentId = await getCurrentLayerId();
        if (!currentId) {
            const list = await getLayerList();
            if (list.length > 0) {
                await switchToLayer(list[0], false, true);
            }
        }

        modal.addEventListener('remove', () => {
            destroyEditor();
        });

        async function renderLayerList() {
            const list = await getLayerList();
            const currentId = await getCurrentLayerId();
            layerSelect.innerHTML = '';
            for (const id of list) {
                const opt = document.createElement('option');
                opt.value = id;
                const data = await loadLayer(id);
                const displayName = data?.name || id;
                opt.textContent = displayName;
                if (id === currentId) opt.selected = true;
                layerSelect.appendChild(opt);
            }
            currentLayerName = currentId ? ((await loadLayer(currentId))?.name || currentId) : '';
        }

        async function switchToLayer(layerId, saveCurrent = true, skipRenderList = false) {
            if (saveCurrent) {
                saveGraphData();
            }
            const data = await loadLayer(layerId);
            if (data) {
                languages = data.languages || ['ru'];
                nodes = data.nodes.map(n => ({ ...n, _img: null }));
                edges = data.edges.map(e => ({ ...e }));
                currentLayerName = data.name || layerId;
                currentEditLang = data.selectedLang || languages[0] || 'ru';
            } else {
                languages = ['ru'];
                nodes = [];
                edges = [];
                currentLayerName = layerId;
                currentEditLang = 'ru';
            }
            await setCurrentLayerId(layerId);

            selectedNodeId = null;
            selectedEdgeIds = [];
            selectedFromEdge = false;
            edgeSelectionSource = null;
            showNodeInfo(null);
            hideCategoryDropdown();
            hideGroupsDropdown();

            updateLangButtons();
            if (!skipRenderList) {
                renderLayerList();
            }
            requestRedraw();
        }

        async function createNewLayer() {
            const name = await generateLayerName();
            const newId = await createLayer(name);
            await switchToLayer(newId, true);
        }

        async function deleteCurrentLayer() {
            const currentId = await getCurrentLayerId();
            const data = await loadLayer(currentId);
            const name = data?.name || currentId;
            const confirmed = await showDialog({
                title: 'Подтверждение',
                message: L10N.t('layer_delete_confirm', { name }),
                type: 'confirm',
                zIndex: 10000009
            });
            if (!confirmed) return;

            await deleteLayer(currentId);
            const remaining = await getLayerList();
            if (remaining.length > 0) {
                const newId = remaining[0];
                await switchToLayer(newId, false);
            } else {
                languages = ['ru'];
                nodes = [];
                edges = [];
                currentEditLang = 'ru';
                currentLayerName = '';
                await setCurrentLayerId(null);
                selectedNodeId = null;
                selectedEdgeIds = [];
                showNodeInfo(null);
                renderLayerList();
                requestRedraw();
            }
        }

        async function renameLayer() {
            const currentId = await getCurrentLayerId();
            if (!currentId) return;
            const data = await loadLayer(currentId);
            const oldName = data?.name || currentId;
            const newName = await showDialog({
                title: L10N.t('layer_renamed'),
                message: L10N.t('layer_name_prompt'),
                type: 'prompt',
                defaultValue: oldName,
                placeholder: 'Новое имя...',
                zIndex: 10000009
            });
            if (newName === null || newName.trim() === '') return;
            const trimmed = newName.trim();
            if (trimmed === oldName) return;
            const list = await getLayerList();
            if (list.includes(trimmed) && trimmed !== currentId) {
                showDialog({ title: 'Ошибка', message: L10N.t('layer_name_exists'), type: 'alert', zIndex: 10000009 });
                return;
            }

            const layerData = { ...data };
            delete layerData.id;
            layerData.name = trimmed;

            await saveLayer(trimmed, layerData);
            await deleteLayer(currentId);
            await setCurrentLayerId(trimmed);
            await switchToLayer(trimmed);
        }

        layerSelect.addEventListener('change', function() {
            const newId = this.value;
            if (newId && newId !== getCurrentLayerId()) {
                switchToLayer(newId);
            }
        });

        addLayerBtn.addEventListener('click', createNewLayer);
        delLayerBtn.addEventListener('click', deleteCurrentLayer);
        renameBtn.addEventListener('click', renameLayer);

        function openLanguageManager() {
            const overlay = document.createElement('div');
            overlay.className = 'lore-modal-overlay';
            overlay.style.zIndex = '10000008';
            const box = document.createElement('div');
            box.className = 'lore-modal-box';
            box.style.maxWidth = '600px';
            box.style.maxHeight = '95vh';
            box.style.display = 'flex';
            box.style.flexDirection = 'column';
            box.style.overflow = 'hidden';

            const title = document.createElement('h3');
            title.className = 'lore-modal-title';
            title.textContent = L10N.t('lang_manage_title');
            box.appendChild(title);

            const langList = document.createElement('div');
            langList.id = 'lang-list';
            langList.style.cssText = 'margin-bottom:12px;max-height:300px;min-height:150px;overflow-y:auto;border-bottom:1px solid #45475a;padding-bottom:8px;flex-shrink:0;';
            box.appendChild(langList);

            const gridContainer = document.createElement('div');
            gridContainer.style.cssText = 'flex:1;overflow-y:auto;margin-bottom:8px;';
            const grid = document.createElement('div');
            grid.id = 'lang-grid';
            grid.style.cssText = 'display:grid;grid-template-columns:repeat(3,1fr);gap:4px;';
            gridContainer.appendChild(grid);
            box.appendChild(gridContainer);

            const closeBtn = document.createElement('button');
            closeBtn.className = 'lore-btn lore-btn-ghost';
            closeBtn.style.cssText = 'display:block;margin:0 auto;flex-shrink:0;';
            closeBtn.textContent = L10N.t('close_btn');
            box.appendChild(closeBtn);

            overlay.appendChild(box);
            document.body.appendChild(overlay);

            function renderLangList() {
                langList.innerHTML = '';
                languages.forEach(lang => {
                    const row = document.createElement('div');
                    row.className = 'lore-list-item' + (lang === currentEditLang ? ' lore-list-item-active' : '');
                    const nameSpan = document.createElement('span');
                    nameSpan.className = 'lore-list-name';
                    const nativeName = ISO_LANGUAGES_NATIVE[lang] || lang;
                    nameSpan.textContent = lang === currentEditLang ?
                        `✅ ${lang.toUpperCase()} (${nativeName})` :
                        `⬜ ${lang.toUpperCase()} (${nativeName})`;
                    row.appendChild(nameSpan);

                    const delBtn = document.createElement('button');
                    delBtn.className = 'lore-btn lore-btn-danger';
                    delBtn.textContent = L10N.t('lang_delete');
                    delBtn.style.padding = '2px 8px';
                    delBtn.style.fontSize = '12px';
                    if (languages.length <= 1) {
                        delBtn.disabled = true;
                        delBtn.style.opacity = '0.5';
                        delBtn.title = L10N.t('lang_cant_delete_last');
                    }
                    delBtn.addEventListener('click', async function(e) {
                        e.stopPropagation();
                        if (languages.length <= 1) return;
                        const confirmed = await showDialog({
                            title: '⚠️' + L10N.t('lang_delete_confirm'),
                            message: '',
                            type: 'confirm',
                            zIndex: 10000010
                        });
                        if (!confirmed) return;
                        if (removeLanguage(lang)) {
                            renderLangList();
                            renderAllLanguagesGrid();
                            updateLangButtons();
                            if (selectedNodeId) {
                                const node = nodes.find(n => n.id === selectedNodeId);
                                if (node) showNodeInfo(node);
                            }
                            requestRedraw();
                        }
                    });
                    row.appendChild(delBtn);

                    row.addEventListener('click', function() {
                        currentEditLang = lang;
                        saveGraphData();
                        renderLangList();
                        renderAllLanguagesGrid();
                        updateLangButtons();
                        if (selectedNodeId) {
                            const node = nodes.find(n => n.id === selectedNodeId);
                            if (node) showNodeInfo(node);
                        }
                        requestRedraw();
                    });
                    langList.appendChild(row);
                });
            }

            function renderAllLanguagesGrid() {
                grid.innerHTML = '';
                const sorted = Object.entries(ISO_LANGUAGES_NATIVE).sort((a, b) => a[0].localeCompare(b[0]));
                for (const [code, nativeName] of sorted) {
                    const item = document.createElement('div');
                    const isAdded = languages.includes(code);
                    item.style.cssText = `
                        padding: 4px 6px;
                        border-radius: 4px;
                        border: 1px solid #45475a;
                        background: ${isAdded ? '#313244' : '#1e1e2e'};
                        color: ${isAdded ? '#a6adc8' : '#cdd6f4'};
                        cursor: ${isAdded ? 'default' : 'pointer'};
                        font-size: 14px;
                        text-align: center;
                        transition: 0.1s;
                        user-select: none;
                        ${isAdded ? 'opacity:0.5;' : ''}
                    `;
                    if (!isAdded) {
                        item.addEventListener('mouseenter', () => { item.style.background = '#45475a'; });
                        item.addEventListener('mouseleave', () => { item.style.background = '#1e1e2e'; });
                        item.addEventListener('click', function() {
                            addLanguage(code);
                            renderLangList();
                            renderAllLanguagesGrid();
                            updateLangButtons();
                            if (selectedNodeId) {
                                const node = nodes.find(n => n.id === selectedNodeId);
                                if (node) showNodeInfo(node);
                            }
                            requestRedraw();
                        });
                    } else {
                        item.title = 'Уже добавлен';
                    }
                    item.textContent = `${code} (${nativeName})`;
                    grid.appendChild(item);
                }
            }

            const closeModal = () => {
                overlay.remove();
                updateLangButtons();
                requestRedraw();
                if (selectedNodeId) {
                    const node = nodes.find(n => n.id === selectedNodeId);
                    if (node) showNodeInfo(node);
                }
            };

            closeBtn.addEventListener('click', closeModal);
            overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); });

            renderLangList();
            renderAllLanguagesGrid();
            updateLangButtons();
        }

        let tooltipTimeout = null,
            tooltipBox = null;

        function showTooltip(e, text) {
            if (tooltipBox) { tooltipBox.remove();
                tooltipBox = null; }
            tooltipBox = document.createElement('div');
            tooltipBox.className = 'tooltip-box';
            tooltipBox.textContent = text;
            document.body.appendChild(tooltipBox);
            const rect = e.target.getBoundingClientRect();
            let left = rect.right + 8,
                top = rect.top + (rect.height / 2) - (tooltipBox.offsetHeight / 2);
            if (left + 260 > window.innerWidth) left = rect.left - 260 - 8;
            if (top < 10) top = 10;
            if (top + tooltipBox.offsetHeight > window.innerHeight - 10) top = window.innerHeight - tooltipBox.offsetHeight - 10;
            tooltipBox.style.left = left + 'px';
            tooltipBox.style.top = top + 'px';
            tooltipBox.style.display = 'block';
        }

        function hideTooltip() { if (tooltipBox) { tooltipBox.remove();
                tooltipBox = null; } }

        document.addEventListener('mouseenter', e => {
            if (e.target && typeof e.target.closest === 'function') {
                const target = e.target.closest('.tooltip-icon');
                if (target) {
                    const key = target.dataset.tooltip;
                    const text = L10N.t('tooltip_' + key);
                    if (text && text !== 'tooltip_' + key) {
                        if (tooltipTimeout) clearTimeout(tooltipTimeout);
                        tooltipTimeout = setTimeout(() => showTooltip(e, text), 200);
                    }
                }
            }
        }, true);

        document.addEventListener('mouseleave', e => {
            if (e.target && typeof e.target.closest === 'function' && e.target.closest('.tooltip-icon')) {
                if (tooltipTimeout) { clearTimeout(tooltipTimeout);
                    tooltipTimeout = null; }
                hideTooltip();
            }
        }, true);
        window.addEventListener('scroll', hideTooltip);
        window.addEventListener('resize', hideTooltip);

        await renderLayerList();
        updateZoomButtons();
        window._graphEditorInstance = { switchToLayer };
    }

    // ─── API библиотеки ────────────────────────────────────────────────
    (function setupAPI() {
        if (typeof window.__LOREGRAPH__ !== 'undefined') return;

        let subscribers = [];

        async function getLorebooks() {
            const allIds = await getLayerList();
            const result = [];

            for (const id of allIds) {
                const data = await loadLayer(id);
                if (!data || !data.enabled) continue;

                const lang = data.selectedLang || data.languages?.[0] || 'en';
                // ★ Фильтруем только активные узлы ★
                const nodes = (data.nodes || [])
                    .filter(node => node.active !== false)
                    .map(node => {
                        const i18n = node.i18n?.[lang] || { label: '', description: '', comment: '', category: '' };
                        const { i18n: _, _img, ...rest } = node;
                        return {
                            ...rest,
                            label: i18n.label || '',
                            description: i18n.description || '',
                            comment: i18n.comment || '',
                            category: i18n.category || ''
                        };
                    });

                result.push({
                    name: data.name || id,
                    language: lang,
                    nodes: nodes,
                    edges: data.edges || [],
                    order: data.order || 0
                });
            }

            // Сортируем по order
            result.sort((a, b) => (a.order ?? 9999) - (b.order ?? 9999));

            // Убираем order из финального результата
            return result.map(({ order, ...rest }) => rest);
        }

        window.__LOREGRAPH__ = {
            getLorebooks,
            subscribe(callback) {
                if (typeof callback === 'function') {
                    subscribers.push(callback);
                    return () => {
                        subscribers = subscribers.filter(cb => cb !== callback);
                    };
                }
                return null;
            },
            _notify() {
                subscribers.forEach(cb => { try { cb(); } catch (_) {} });
            }
        };
    })();

    // ─── Регистрация в диспетчере ─────────────────────────────────────
    let moduleRegistered = false;

    function registerLibraryModule() {
        if (window.__MANAGER__) {
            if (moduleRegistered) window.__MANAGER__.close('library');
        }
        window.__MANAGER__.register('library', {
            title: '🌐 ' + L10N.t('editor_title'),
            type: 'interface',
            content: getLibraryContent,
            onEnable: function(chatId) {
                if (typeof window.__LOREGRAPH__ === 'undefined') {
                    if (typeof setupAPI === 'function') setupAPI();
                }
            },
            onDisable: function() {},
            onActivate: function() { bindButtons(); },
            onDeactivate: function() {},
            onClose: function() { moduleRegistered = false; }
        });
        moduleRegistered = true;
        console.log('✅ Редактор библиотеки зарегистрирован');
    }

    function bindButtons() {
        setTimeout(function() {
            const openBtn = document.getElementById('lib-open-editor');
            if (openBtn) {
                openBtn.onclick = function() { openGraphEditor(); };
                openBtn.style.cssText = 'background:#a6e3a1;color:#111;border:1px solid #8ccf89;border-radius:4px;padding:6px 12px;font-weight:bold;cursor:pointer;transition:0.15s;';
            }

            const managerBtn = document.getElementById('lib-manager-btn');
            if (managerBtn) {
                managerBtn.onclick = function() { openLorebookManager(); };
                managerBtn.style.cssText = 'background:#89b4fa;color:#111;border:1px solid #6a9bd6;border-radius:4px;padding:6px 12px;font-weight:bold;cursor:pointer;transition:0.15s;';
            }
        }, 0);
    }

    // Обработчики событий (без перерегистрации)
    window.addEventListener('languageChanged', function(e) {
        const newLang = e.detail.lang;
        L10N.setLang(newLang);
        if (window.__MANAGER__) {
            window.__MANAGER__.update('library', getLibraryContent());
          window.__MANAGER__.setModuleTitle('library', '🌐 ' + L10N.t('editor_title'));
            bindButtons();
        }
        const editor = document.getElementById('lore-graph-editor');
        if (editor) {
            editor.remove();
            openGraphEditor();
        }
    });

    window.addEventListener('libraryLangChanged', function(e) {
        const newLang = e.detail.lang;
        const editor = document.getElementById('lore-graph-editor');
        if (editor) {
            editor.remove();
            openGraphEditor();
        }
    });

    // Подписка на смену чата – автоматически переключает активные лорбуки
    if (window.__MANAGER__) {
        window.__MANAGER__.addHook('chatChanged', async (newChatId) => {
            // 1. Сбрасываем все слои (отключаем)
            const allIds = await getLayerList();
            for (const id of allIds) {
                const layer = await loadLayer(id);
                if (layer && layer.enabled) {
                    layer.enabled = false;
                    await saveLayer(id, layer);
                }
            }

            // 2. Включаем только те, что сохранены в moduleSettings библиотеки для этого чата
            if (newChatId) {
                const libSettings = await window.__MANAGER__.getModuleSettings('library');
                const lorebooks = libSettings?.lorebooks || [];
                for (const lb of lorebooks) {
                    const layer = await loadLayer(lb.id);
                    if (layer) {
                        layer.enabled = true;
                        layer.order = lb.order ?? 0;
                        if (lb.language) layer.selectedLang = lb.language;
                        await saveLayer(lb.id, layer);
                    }
                }
                // Обновляем moduleSettings (на случай, если что-то изменилось)
                await syncLorebooksForChat(newChatId);
            }
        });
    }

    // Синхронная регистрация
    registerLibraryModule();
    console.log('🌐 World Library Editor loaded.');
})();