cverinc_marktile/main.js

1544 lines
201 KiB
JavaScript
Raw Normal View History

'use strict';
const Sortable = (function () {
var module = { exports: {} }; var exports = module.exports;
/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.le
return module.exports;
})();
/* marktile the tile-family standalone editor. Opens any .md note in the SAME world-class editor as
tugtile (headings grow while the '## ' markers stay; CJK-safe contenteditable; smart-Enter lists).
It is a switchable pane: the leaf becomes marktile, with a header button back to Obsidian's editor and
Obsidian's editor gets a button over to marktile. No global hijack (registerExtensions), so the native
editor is always one tap away and a bug can never lock you out of a note.
Built by build-marktile.sh, which (1) injects i18n into the TR object below and (2) inlines the shared
core blocks (marked core-start / core-end in ../plugin.src.js) at the core-inline line. */
const { Plugin, Notice, TextFileView, Modal, setIcon, Platform, PluginSettingTab, Setting } = require('obsidian'); // Modal/setIcon/Platform are used by the inlined core editor; PluginSettingTab/Setting for the settings tab
// ---- i18n (mirrors tugtile; the same i18n/*.json is injected at build) ----
const LOCALE = (() => {
let lang = '';
try { lang = (window.localStorage.getItem('language') || ''); } catch (e) { lang = ''; }
if (lang === 'zh-TW') return 'zh-TW';
if (lang === 'ja') return 'ja-JP';
if (lang === 'ko') return 'ko-KR';
return 'en-US';
})();
const TR = {"en-US": {"appName": "tugtile", "brandSuffix": "tugtile-ing", "brandSuffixLocked": "tugtile", "lockToggle": "Lock / unlock board", "lockedNotice": "Board is locked", "undoAction": "Undo", "redoAction": "Redo", "viewSwitchAction": "Switch view (Board / Table)", "boardSettingsAction": "Board settings", "openAsMarkdownAction": "Open as markdown", "archiveAction": "Stash (Archive)", "searchAction": "Search tiles", "emptyNoFile": "Open a board .md with the “Open as tugtile” command.", "fileNotFound": "File not found: {0}", "searchPlaceholder": "Find a tile", "viewBoard": "Board", "viewTable": "Table", "editMarkdown": "Edit raw markdown", "findPlaceholder": "Find", "replacePlaceholder": "Replace", "findPrev": "Previous", "findNext": "Next", "replaceOne": "Replace", "replaceAll": "Replace all", "colTile": "Tile", "colLane": "Lane", "colDate": "Date", "colTags": "Tags", "collapseExpand": "Collapse / expand", "laneActionsAria": "Lane actions (rename / insert / sort / stash / delete…)", "tileActionsAria": "More actions (edit / stash / delete…)", "relDateWrap": " ({0})", "today": "today", "tomorrow": "tomorrow", "yesterday": "yesterday", "dayAfterTomorrow": "in 2 days", "dayBeforeYesterday": "2 days ago", "daysLater": "in {0} days", "daysAgo": "{0} days ago", "yearMonth": "{0}-{1}", "weekdays": ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], "edit": "Edit", "duplicateTile": "Duplicate", "insertTileAbove": "Insert tile above", "insertTileBelow": "Insert tile below", "splitTileMenu": "Split into tiles", "archiveTileMenu": "Stash (Archive)", "moveTileTop": "Move to top", "moveTileBottom": "Move to bottom", "untitledLane": "(untitled)", "moveToLane": "Move to “{0}”", "deleteTileMenu": "Delete", "splitNoNeed": "Only one line — nothing to split.", "splitDone": "Split into {0} tiles", "archivedTile": "Tile stashed", "deletedTile": "Tile deleted", "deletedLane": "Lane deleted", "toastUndoBtn": "Undo", "addTileBtn": " Add a tile", "dropToArchive": "Drop here to stash", "cancel": "Cancel", "save": "Save", "discardConfirm": "Discard your changes?", "editLost": "Tile no longer exists — edit not saved.", "mobileSubmit": "Submit", "addLaneBtn": " Add lane", "addLanePlaceholder": "Lane name — ⏎ to add", "newLane": "New lane", "newBoardName": "New board", "confirmDeleteLane": "This lane has {0} tiles. Delete the whole lane?", "boardListViewOnly": "Use this in Board view", "archivedCompleted": "Stashed {0} completed tiles", "noCompleted": "No completed tiles", "rename": "Rename", "insertLaneBefore": "Insert lane before", "insertLaneAfter": "Insert lane after", "sortTitleAsc": "Sort by tile title A→Z", "sortTitleDesc": "Sort by tile title Z→A", "sortDate": "Sort by date (soonest first)", "sortTag": "Sort by tag", "markLaneComplete": "Mark all in lane complete", "archiveLaneMenu": "Stash all tiles in lane", "deleteLaneMenu": "Delete lane", "confirmArchiveLane": "Stash all {0} tiles in this lane?", "archivedLane": "Stashed {0} tiles from lane", "noLaneToRestore": "tugtile: no lane to restore into — create a lane first", "externalModified": "tugtile: this file was changed elsewhere — reloaded to avoid overwrite (this step was not saved)", "backupFailed": "tugtile: backup failed — write cancelled to protect your data", "writeFailed": "tugtile write failed: {0}", "saved": "Saved", "persistFailed": "tugtile: save failed — {0}", "undoVerb": "undo", "redoVerb": "redo", "noStep": "tugtile: nothing left to {0}", "timeTraveled": "tugtile: {0} done (undo {1} / redo {2})", "archiveTitle": "Stash (Archive)", "archiveEmpty": "No stashed tiles.", "restore": "Restore", "deleteArchived": "Delete", "boardSettingsTitle": "Board settings", "boardSettingsDesc": "Affects only this board (saved with the board file). Blank = follow the global default.", "migrateBtn": "Upgrade to tugtile format", "migrateBtnDesc": "Remove obsidian-kanban markers so this board is tugtile-native. One-way.", "migrateConfirm": "Upgrade this board to tugtiles own format? It will no longer open in obsidian-kanban, and kanban-only settings will be d
function t(key, ...args) {
let s = (TR[LOCALE] && TR[LOCALE][key]);
if (s == null) s = TR['en-US'] && TR['en-US'][key];
if (s == null) return key;
if (typeof s === 'string' && args.length) s = s.replace(/\{(\d+)\}/g, (m, i) => (args[+i] != null ? args[+i] : m));
return s;
}
/* tile-family shared editor core the SINGLE source of the editor engine used by BOTH plugins.
Extracted from tugtile's plugin.src.js (the former //#core-start/#core-end blocks). The builds
inject this file at each plugin's core-inline marker. It uses Obsidian element helpers / setIcon /
Platform / Modal, which both Obsidian-plugin consumers provide (de-Obsidian-ifying for ejecta is a
later, separate step). EDITOR_TOOLS + escHtml + highlighters + listContinuation + tabEdit +
tocHeadings + moveSection + mountEditor + TileEditModal. */
const EDITOR_TOOLS = [
// fixed: always shown, not user-toggleable (essentials). tip = i18n key for the hover/aria label.
{ key: 'search', icon: 'search', fixed: true, tip: 'edFind' }, { key: 'undo', icon: 'undo', fixed: true, tip: 'undoAction' }, { key: 'redo', icon: 'redo', fixed: true, tip: 'redoAction' }, 'sep',
// icons verified present in Obsidian's bundled Lucide subset (not all of Lucide ships); g = text fallback when no icon
{ key: 'h1', g: 'H1', icon: 'heading-1', cat: 'format', tip: 'edH1' }, { key: 'h2', g: 'H2', icon: 'heading-2', cat: 'format', tip: 'edH2' }, { key: 'h3', g: 'H3', icon: 'heading-3', cat: 'format', tip: 'edH3' }, 'sep',
{ key: 'bold', g: 'B', icon: 'bold', cat: 'format', tip: 'edBold' }, { key: 'italic', g: 'I', icon: 'italic', cat: 'format', tip: 'edItalic' }, { key: 'strike', g: 'S', icon: 'strikethrough', cat: 'format', tip: 'edStrike' }, 'rowbreak', // phone: wrap to a third toolbar row here (desktop treats it as a separator)
// block tools (lists / quote / table) — split out of 'format' (which is now just headings + inline marks)
{ key: 'bullet', g: '•', icon: 'list', cat: 'block', tip: 'edBullet' }, { key: 'number', g: '1.', icon: 'list-ordered', cat: 'block', tip: 'edNumber' }, { key: 'check', g: '☑', icon: 'list-checks', cat: 'block', tip: 'edCheck' }, { key: 'quote', g: '❝', icon: 'text-quote', cat: 'block', tip: 'edQuote' }, { key: 'table', g: '⊞', icon: 'table', cat: 'block', tip: 'edTable' }, 'sep',
{ key: 'code', g: '</>', icon: 'code', cat: 'insert', tip: 'edCode' }, { key: 'link', g: '[[ ]]', icon: 'link', cat: 'insert', tip: 'edLink' },
// image/video: capability lives in the core, NOT injected per-surface. Each host wires the platform seam via
// opts.pickImage / opts.pickVideo (Obsidian: vault save / web: upload) — `needs` hides the button when unwired.
{ key: 'image', g: 'IMG', icon: 'image', cat: 'insert', tip: 'edImage', needs: 'pickImage' }, { key: 'video', g: 'VID', icon: 'video', cat: 'insert', tip: 'edVideo', needs: 'pickVideo' }, 'sep',
{ key: 'date', g: '@', icon: 'calendar', cat: 'insert', tip: 'edDate' }, { key: 'time', g: '@@', icon: 'clock', cat: 'insert', tip: 'edTime' },
];
// Centered modal editor for cards: large centered card, darkened background, virtual keyboard adjusts modal container, saves changes on close
function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// Synchronized syntax highlighter: renders raw markdown styling (bold, headings, lists, blockquotes, tags, links, dates).
// Modifies only font weights and colors (maintains font size) to match the textarea line-height, ensuring perfect layout alignment (crucial for this design).
// Highlights ONE markdown line into { cls, inner } for a <div class="tg-line"> block. Markers are KEPT
// (literal '## ' stays visible); heading lines just carry a level class so CSS can size them up. An empty
// line uses <br> (textContent '') so the text<->DOM round-trip stays exact. Shared by the full render and
// the in-place single-line re-highlight (so both produce byte-identical DOM).
function highlightLineParts(line) {
let cls = 'tg-line';
// CommonMark: a heading counts at line start, after ≤3 leading spaces, OR inside a list item. hm[1] swallows the
// optional indent + bullet/checkbox prefix so `- ### x` / `- [ ] ### x` / ` ### x` all size+colour as headings;
// hm[2] is the # run (→ level). It's ADDITIVE with tg-li/tg-task — a heading can also be a list/task line.
const hm = /^(\s*(?:[-*]\s(?:\[[ xX]\]\s)?)?)(#{1,6})\s/.exec(line);
if (hm) cls += ' tg-h tg-h' + hm[2].length;
if (/^>\s?/.test(line)) cls += ' tg-quote';
else if (/^\s*[-*]\s/.test(line)) cls += ' tg-li';
// Each syntax marker (## , &gt; , - , [ ], **, *, ~~, `, [[ ]], @{}) is wrapped in its own <span class="tg-mk">
// so a host can hide JUST the markers via CSS (.tugtile-preview .tg-mk{display:none}) while the styling stays —
// the basis for a marker-free preview look. tg-mk is transparent to the text round-trip (getText reads
// textContent, which still includes the marker chars). The Obsidian plugins never add .tugtile-preview, so
// markers stay visible there (their 調味/原味 cycle is unchanged); only a host that opts in hides them. escHtml
// has already turned a leading > into &gt; (the quote marker), so match that form.
const h = escHtml(line)
.replace(/^(\s*(?:[-*]\s(?:\[[ xX]\]\s)?)?)(#{1,6}\s)/, (m, pre, hashes) => pre + '<span class="tg-mk">' + hashes + '</span>') // heading marker — wraps only the # run, leaving any indent/bullet/checkbox prefix for the rules below to wrap
.replace(/^(&gt;\s?)/, '<span class="tg-mk">$1</span>') // blockquote marker
.replace(/^(\s*[-*]\s)(\[[ xX]\])/, (m, p, box) => '<span class="tg-mk">' + p + '</span><span class="tg-check' + (/[xX]/.test(box) ? ' tg-check-done' : '') + '"><span class="tg-mk">' + box + '</span></span>')
.replace(/^(\s*[-*]\s)/, '<span class="tg-mk">$1</span>') // plain bullet (heading/quote/checkbox lines already start with a <span>, so this won't match them)
.replace(/(\*\*[^*\n]+\*\*|\*[^*\s][^*\n]*?\*)/g, (m) => { const mk = m.startsWith('**') ? '**' : '*'; return '<span class="' + (mk === '**' ? 'tg-b' : 'tg-i') + '"><span class="tg-mk">' + mk + '</span>' + m.slice(mk.length, m.length - mk.length) + '<span class="tg-mk">' + mk + '</span></span>'; }) // **bold** wins the alternation; single * needs a non-space after it so "a * b" isn't italicised
.replace(/(~~[^~\n]+~~)/g, (m) => '<span class="tg-strike"><span class="tg-mk">~~</span>' + m.slice(2, -2) + '<span class="tg-mk">~~</span></span>')
.replace(/(`[^`\n]+`)/g, (m) => '<span class="tg-code"><span class="tg-mk">`</span>' + m.slice(1, -1) + '<span class="tg-mk">`</span></span>')
.replace(/(\[\[[^\]\n]+\]\])/g, (m) => '<span class="tg-link"><span class="tg-mk">[[</span>' + m.slice(2, -2) + '<span class="tg-mk">]]</span></span>')
.replace(/(@@?\{)([^}\n]*)(\})/g, (m, op, inner, cl) => '<span class="tg-date"><span class="tg-mk">' + op + '</span>' + inner + '<span class="tg-mk">' + cl + '</span></span>')
.replace(/(^|[^&\w])(#[^\s#<&]+)/g, '$1<span class="tg-tag">$2</span>')
.replace(/\t/g, '<span class="tg-tab">\t</span>'); // wrap each literal tab LAST (after the line-start regexes) so CSS can mark tab-vs-space; span is transparent to the text round-trip
if (/^\s*[-*]\s\[[ xX]\]/.test(line)) cls += ' tg-task' + (/^\s*[-*]\s\[[xX]\]/.test(line) ? ' tg-task-done' : '');
return { cls: cls, inner: (h || '<br>') };
}
// Renders the whole markdown source into per-line <div> blocks for the contenteditable editor.
function highlightMarkdown(text) {
const lines = (text === '' ? [''] : text.split('\n'));
return lines.map((line) => { const p = highlightLineParts(line); return '<div class="' + p.cls + '">' + p.inner + '</div>'; }).join('');
}
// Smart-Enter list continuation (pure, so it's unit-testable without a DOM). Given the full text and a caret
// offset, returns the new { text, caret } when the caret line is a list item (- / * / 1. / - [ ]), or null
// when it isn't (so the caller lets the native newline happen). A list item that's empty past its marker
// exits the list (marker removed). Ordered markers increment; checkbox items continue UNCHECKED.
function listContinuation(v, s) {
const ls = v.lastIndexOf('\n', s - 1) + 1;
let le = v.indexOf('\n', s); if (le < 0) le = v.length;
const line = v.slice(ls, le);
let prefix = null, contentStart = 0;
const mu = /^(\s*)([-*])\s+(\[[ xX]\]\s+)?/.exec(line);
if (mu) { contentStart = mu[0].length; prefix = mu[1] + mu[2] + ' ' + (mu[3] ? '[ ] ' : ''); }
else { const mo = /^(\s*)(\d+)([.)])\s+/.exec(line); if (mo) { contentStart = mo[0].length; prefix = mo[1] + (parseInt(mo[2], 10) + 1) + mo[3] + ' '; } }
if (prefix === null) return null;
if (line.slice(contentStart).trim() === '') return { text: v.slice(0, ls) + v.slice(le), caret: ls }; // empty item → exit the list
return { text: v.slice(0, s) + '\n' + prefix + v.slice(s), caret: s + 1 + prefix.length }; // continue the list
}
// Re-sequence contiguous top-level ordered-list blocks so they read 1,2,3… A markdown renderer renumbers
// regardless of the literal digits, but marktile SHOWS the markers — so deleting "2." should make the old
// "3." become "2.". Pure (unit-testable) and, for single-digit lists, caret-stable (a renumbered marker is
// the same width). A blank or non-ordered line ends a block; indented/nested ordered lists are left alone.
// Returns the SAME string when already sequential, so callers can no-op on identity (the common case).
function renumberLists(v) {
const lines = v.split('\n');
let n = 0, changed = false;
for (let i = 0; i < lines.length; i++) {
const m = /^(\d+)([.)])(\s)/.exec(lines[i]);
if (m) { n++; const want = n + m[2] + m[3]; if (m[0] !== want) { lines[i] = want + lines[i].slice(m[0].length); changed = true; } }
else if (/^[ \t]+\S/.test(lines[i])) continue; // indented continuation / nested list → part of the current item, leave the top-level counter alone
else n = 0; // blank / non-indented non-ordered line breaks the block → next ordered run restarts at 1
}
return changed ? lines.join('\n') : v;
}
// Tab inserts a literal tab at the caret (replacing any selection); Shift+Tab removes one tab immediately
// before a collapsed caret. Tugtile's tile structure is tab-indented (serializeTile re-adds it on write),
// so being able to type a real tab matters when editing a raw board file in marktile. Pure → unit-tested.
function tabEdit(v, s, e, outdent) {
if (outdent) {
if (s === e && s > 0 && v[s - 1] === '\t') return { text: v.slice(0, s - 1) + v.slice(s), caret: s - 1 };
return null; // nothing to outdent
}
return { text: v.slice(0, s) + '\t' + v.slice(e), caret: s + 1 };
}
// Table-of-contents model (pure → unit-tested). Scans markdown for H1H3 headings OUTSIDE fenced code blocks,
// returning { level, text, line } per heading. `line` is the 0-based source line index, which maps 1:1 to the
// editor's .tg-line divs (highlightMarkdown emits one div per line) so the consumer can scroll straight to it.
function tocHeadings(text) {
const lines = String(text).split('\n');
const out = [];
let fence = false;
for (let i = 0; i < lines.length; i++) {
if (/^\s*(```|~~~)/.test(lines[i])) { fence = !fence; continue; } // a fence line toggles in/out of code (its own # are not headings)
if (fence) continue;
const m = /^(#{1,3})\s+(.*)$/.exec(lines[i]);
if (m) out.push({ level: m[1].length, text: m[2].trim(), line: i });
}
return out;
}
// Drag-reorder a TOC section (pure → unit-tested). oldIndex/newIndex are heading positions in tocHeadings()
// order (= SortableJS evt.oldIndex/newIndex). A "section" = the heading line through everything up to the next
// heading of EQUAL-OR-HIGHER level (so dragging an H1 carries its H2/H3 children; level B: levels never change).
// Moving down lands it after the target's whole section; moving up lands it before the target.
function moveSection(text, oldIndex, newIndex) {
if (oldIndex === newIndex) return text;
const lines = String(text).split('\n');
const heads = tocHeadings(text);
const n = heads.length;
if (oldIndex < 0 || oldIndex >= n || newIndex < 0 || newIndex >= n) return text;
const sectionEnd = (idx) => { const lv = heads[idx].level; for (let j = idx + 1; j < n; j++) if (heads[j].level <= lv) return heads[j].line; return lines.length; };
const start = heads[oldIndex].line, end = sectionEnd(oldIndex);
const block = lines.slice(start, end);
const insertAt = (newIndex > oldIndex) ? sectionEnd(newIndex) : heads[newIndex].line; // after target's section (down) / before target (up)
const rest = lines.slice(0, start).concat(lines.slice(end));
const ins = (insertAt >= end) ? insertAt - block.length : insertAt; // shift left if the removed block sat before the insertion point
return rest.slice(0, ins).concat(block, rest.slice(ins)).join('\n');
}
// Builds the reusable contenteditable editor into a container; returns a controller. Hosted by the modal
// (kanban cards) and by marktile's file view (standalone .md). opts: { text, onCancel?, onSave?,
// onSubmit?, onEscape?, onChange? }. host = the board view or a minimal file host (see interface above).
function mountEditor(contentEl, opts, host) {
const orig = opts.text || '';
contentEl.empty(); contentEl.addClass('tugtile-edit-modal');
// Title bar: Cancel (✕) on the left, tool actions in the center, Save (✓) on the right (positioned at the top to avoid virtual keyboard occlusion)
const bar = contentEl.createDiv({ cls: 'tugtile-ed-bar' });
// Virtual keyboard workaround: call preventDefault on mousedown/pointerdown to block focus transfer.
// This keeps the keyboard open, prevents viewport reflows, and ensures the tap action is registered properly.
// The textarea retains focus and values during execution before closing. This technique is verified and reused in tbtn shortcut buttons.
const tap = (el, fn) => {
el.addEventListener('mousedown', (e) => e.preventDefault()); // Prevents stealing focus from the textarea (mouse/synthetic events)
el.addEventListener('pointerdown', (e) => e.preventDefault()); // Touch/stylus: same as above (blocks focus transfer only, allows scrolling)
el.addEventListener('touchstart', (e) => { e.preventDefault(); fn(); }, { passive: false }); // Touch: triggers immediately + retains focus + blocks synthetic click
el.addEventListener('click', fn); // Mouse/desktop click
};
if (opts.onToc) { const tc = bar.createEl('button', { cls: 'tugtile-iconbtn tugtile-ed-toc' }); setIcon(tc.createSpan(), 'list-tree'); tc.setAttribute('aria-label', t('mtToc')); tap(tc, opts.onToc); } // TOC toggle — sits in the ✕'s left slot; only when the host wants it (marktile passes onToc; tugtile's card modal doesn't)
if (opts.onCancel) { const x = bar.createEl('button', { cls: 'tugtile-iconbtn tugtile-ed-x' }); setIcon(x.createSpan(), 'x'); x.setAttribute('aria-label', t('cancel')); tap(x, opts.onCancel); } // ✕ — Lucide icon (matches the toolbar), span-nested for iPad; only when the host wants a cancel affordance (modal)
const tools = bar.createDiv({ cls: 'tugtile-ed-tools' });
if (opts.onSave) { const ok = bar.createEl('button', { cls: 'tugtile-iconbtn tugtile-ed-ok' }); setIcon(ok.createSpan(), 'check'); ok.setAttribute('aria-label', t('save')); tap(ok, opts.onSave); } // ✓ — Lucide check; only for the modal (the file view autosaves)
// Phone: split the toolbar — the top bar keeps the essentials (search/undo/redo, centered between ✕ ✓),
// the format/insert tools drop to a second row below so the cramped phone bar isn't a long scroll.
const twoRow = Platform.isPhone;
// Phone: two rows. Top = the bar itself (✕ [undo·redo·headings·bold/italic/strike] ✓). Bottom = tools2
// (search + lists/quote/code/link/date/time). The dedicated essentials bar is gone — everything moved up.
const tools2 = twoRow ? contentEl.createDiv({ cls: 'tugtile-ed-tools2' }) : null;
// Editor body: a single contenteditable surface. A <textarea> can only carry one uniform font, so it
// can never size a heading line up. contenteditable can — each line keeps its literal markdown ('## ')
// AND renders bigger (no Obsidian-style concealment). The visible text IS the editable text (one layer),
// so the caret needs no overlay alignment. A scroll wrapper owns scrolling so touch-drag works unfocused.
const scroll = contentEl.createDiv({ cls: 'tugtile-ed-scroll' });
const ed = scroll.createDiv({ cls: 'tugtile-ed tugtile-ed-rich', attr: { contenteditable: 'true', spellcheck: 'false', autocapitalize: 'off' } });
// --- Text <-> DOM model: each line is a top-level <div class="tg-line">; an empty line is <div><br></div>. ---
const textOfLine = (d) => (d ? d.textContent : '');
const getText = () => {
const kids = ed.childNodes;
let allDiv = kids.length > 0;
for (const n of kids) { if (!(n.nodeType === 1 && n.tagName === 'DIV')) { allDiv = false; break; } }
if (allDiv) return Array.from(kids).map((d) => d.textContent).join('\n');
// Transient fallback (right after a native keystroke, before re-highlight normalizes the DOM back to clean line divs)
let out = '';
const walk = (n) => {
if (n.nodeType === 3) { out += n.nodeValue; return; }
if (n.tagName === 'BR') { out += '\n'; return; }
if ((n.tagName === 'DIV' || n.tagName === 'P') && out && !out.endsWith('\n')) out += '\n';
for (const ch of n.childNodes) walk(ch);
};
for (const n of kids) walk(n);
return out.replace(/\u200b/g, '');
};
let lineCount = 0;
const render = (text) => { ed.innerHTML = highlightMarkdown(text); lineCount = ed.children.length; };
// --- Caret <-> character offset, so toolbar ops / find-replace / undo can address the document linearly ---
const charsBeforeInLine = (lineEl, node, off) => {
if (node === lineEl) { let c = 0; for (let i = 0; i < off; i++) c += (lineEl.childNodes[i].textContent || '').length; return c; }
let count = 0, done = false;
const walk = (n) => {
if (done) return;
if (n === node) { count += off; done = true; return; }
if (n.nodeType === 3) { count += n.nodeValue.length; return; }
for (const ch of n.childNodes) { walk(ch); if (done) return; }
};
walk(lineEl);
return count;
};
const offsetAt = (node, off) => {
const lines = Array.from(ed.children);
if (node === ed) { let t = 0; for (let i = 0; i < off; i++) t += textOfLine(lines[i]).length + 1; return t; }
let lineEl = node; while (lineEl && lineEl.parentNode !== ed) lineEl = lineEl.parentNode;
const idx = lines.indexOf(lineEl); if (idx < 0) return 0;
let t = 0; for (let i = 0; i < idx; i++) t += textOfLine(lines[i]).length + 1;
return t + charsBeforeInLine(lineEl, node, off);
};
const locateInLine = (lineEl, within) => {
let remaining = within, res = null;
const walk = (n) => {
if (res) return;
if (n.nodeType === 3) { const L = n.nodeValue.length; if (remaining <= L) { res = { node: n, off: remaining }; return; } remaining -= L; return; }
for (const ch of n.childNodes) { walk(ch); if (res) return; }
};
walk(lineEl);
return res || { node: lineEl, off: 0 };
};
const locate = (target) => {
const lines = Array.from(ed.children); let acc = 0;
for (let i = 0; i < lines.length; i++) { const len = textOfLine(lines[i]).length; if (target <= acc + len) return locateInLine(lines[i], target - acc); acc += len + 1; }
const last = lines[lines.length - 1];
return last ? locateInLine(last, textOfLine(last).length) : { node: ed, off: 0 };
};
let lastSel = { start: 0, end: 0 };
const readSel = () => {
const s = window.getSelection();
if (!s || s.rangeCount === 0) return null;
const r = s.getRangeAt(0);
if (!ed.contains(r.startContainer)) return null;
return { start: offsetAt(r.startContainer, r.startOffset), end: offsetAt(r.endContainer, r.endOffset) };
};
const sel = () => readSel() || lastSel;
const setSel = (s, e) => {
const a = locate(s), b = locate(e === undefined ? s : e);
const r = document.createRange(); r.setStart(a.node, a.off); r.setEnd(b.node, b.off);
const ws = window.getSelection(); ws.removeAllRanges(); ws.addRange(r);
lastSel = { start: s, end: (e === undefined ? s : e) };
};
['keyup', 'mouseup', 'touchend'].forEach((ev) => ed.addEventListener(ev, () => { const r = readSel(); if (r) lastSel = r; }));
// --- Undo / redo: our own snapshot stack (innerHTML rebuilds wipe the browser's native undo history) ---
let hist = [], hi = -1;
const pushHist = () => {
const snap = { v: getText(), s: (readSel() || lastSel) };
if (hi >= 0 && hist[hi].v === snap.v) { hist[hi].s = snap.s; return; }
const seeded = hi >= 0; // false only for the very first (seed) snapshot on mount
hist = hist.slice(0, hi + 1); hist.push(snap); if (hist.length > 200) hist.shift(); hi = hist.length - 1;
if (seeded && opts.onChange) opts.onChange(); // notify the host (marktile autosave) only for real edits, not the initial mount seed (B3)
};
const restore = (snap) => { render(snap.v); setSel(snap.s.start, snap.s.end); ed.focus(); };
// --- Re-highlight, caret-preserving. Skipped while an IME is composing so CJK input is never interrupted. ---
let composing = false, syncT = null;
const rehighlight = () => { const r = readSel() || lastSel; render(getText()); setSel(r.start, r.end); };
// Fast path: re-highlight ONLY the caret's line in place (no whole-document rebuild). Returns false —
// forcing a full rehighlight — whenever the structure changed (line added/removed, content merged, or a
// ranged selection), so the worst case is exactly the old behavior.
const rehighlightLine = () => {
const s = window.getSelection();
if (!s || s.rangeCount === 0) return false;
const r = s.getRangeAt(0);
if (!r.collapsed || !ed.contains(r.startContainer)) return false;
if (ed.children.length !== lineCount) return false;
let lineEl = r.startContainer; while (lineEl && lineEl.parentNode !== ed) lineEl = lineEl.parentNode;
if (!lineEl || lineEl.parentNode !== ed) return false;
const text = lineEl.textContent;
if (text.indexOf('\n') >= 0) return false;
const within = charsBeforeInLine(lineEl, r.startContainer, r.startOffset);
const p = highlightLineParts(text);
lineEl.className = p.cls; lineEl.innerHTML = p.inner;
const loc = locateInLine(lineEl, within);
const nr = document.createRange(); nr.setStart(loc.node, loc.off); nr.collapse(true);
s.removeAllRanges(); s.addRange(nr);
const off = offsetAt(loc.node, loc.off); lastSel = { start: off, end: off };
return true;
};
const scheduleSync = () => { clearTimeout(syncT); syncT = setTimeout(() => { if (composing) return; const v = getText(), r = renumberLists(v); if (r !== v) { const c = readSel() || lastSel; render(r); setSel(c.start, c.end); pushHist(); return; } if (!rehighlightLine()) rehighlight(); pushHist(); }, 140); }; // on idle: if an ordered list fell out of sequence (item deleted/moved) renumber it once; else the normal light rehighlight
ed.addEventListener('compositionstart', () => { composing = true; });
ed.addEventListener('compositionend', () => { composing = false; scheduleSync(); });
ed.addEventListener('input', () => { if (!composing) scheduleSync(); });
render(orig); pushHist();
const scrollCaretIntoView = () => { let el = locate(sel().start).node; if (el.nodeType === 3) el = el.parentElement; if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }); };
// Programmatic edit (toolbar / find-replace): replace whole text, set caret, snapshot for undo
const applyEdit = (newText, s, e) => { if (ed.getAttribute('contenteditable') === 'false') return; render(newText); setSel(s, (e === undefined ? s : e)); ed.focus(); pushHist(); }; // read-only guard: blocks toolbar + find/replace edits when the host locks the editor (B2)
// Adapter exposing the slice of the <textarea> API the rest of the modal (and attachDatePicker) relies on
const ta = {
get value() { return getText(); },
set value(v) { render(v); },
get selectionStart() { return sel().start; },
get selectionEnd() { return sel().end; },
setSelectionRange(s, e) { setSel(s, e); },
focus() { ed.focus(); },
get scrollTop() { return scroll.scrollTop; }, set scrollTop(y) { scroll.scrollTop = y; },
get clientHeight() { return scroll.clientHeight; },
getBoundingClientRect() { return ed.getBoundingClientRect(); },
addEventListener(...a) { ed.addEventListener(...a); },
setAttribute() {},
};
// ---- Find / replace (toggled by the 🔍 toolbar button) ----
const findbar = contentEl.createDiv({ cls: 'tugtile-ed-find' });
contentEl.insertBefore(findbar, scroll); // Between the toolbar and the editor body
findbar.style.display = 'none';
const findInp = findbar.createEl('input', { cls: 'tugtile-ed-find-i', type: 'text', attr: { placeholder: t('findPlaceholder') } });
const findN = findbar.createSpan({ cls: 'tugtile-ed-find-n' });
const replInp = findbar.createEl('input', { cls: 'tugtile-ed-find-i', type: 'text', attr: { placeholder: t('replacePlaceholder') } });
const lc = (s) => (s || '').toLowerCase();
const updateN = () => { const term = findInp.value; findN.textContent = term ? String(lc(ta.value).split(lc(term)).length - 1) : ''; };
const findNext = (back) => {
const term = findInp.value; if (!term) return;
const hay = lc(ta.value), needle = lc(term);
let idx;
if (back) { idx = hay.lastIndexOf(needle, Math.max(0, ta.selectionStart - 1)); if (idx < 0) idx = hay.lastIndexOf(needle); }
else { idx = hay.indexOf(needle, ta.selectionEnd); if (idx < 0) idx = hay.indexOf(needle); } // wrap around
if (idx < 0) return;
ed.focus(); setSel(idx, idx + term.length); scrollCaretIntoView();
};
const doReplace = () => {
const term = findInp.value; if (!term) return;
const v = getText(), s = sel().start, e = sel().end;
if (lc(v.slice(s, e)) === lc(term)) applyEdit(v.slice(0, s) + replInp.value + v.slice(e), s + replInp.value.length);
findNext(false); updateN();
};
const doReplaceAll = () => {
const term = findInp.value; if (!term) return;
const re = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
applyEdit(getText().replace(re, replInp.value), sel().start); updateN();
};
const toggleFind = (show) => {
const on = (show === undefined) ? (findbar.style.display === 'none') : show;
findbar.style.display = on ? '' : 'none';
if (on) { updateN(); setTimeout(() => findInp.focus(), 0); } else { ta.focus(); }
};
const mkFb = (icon, aria, fn) => { const b = findbar.createEl('button', { cls: 'tugtile-iconbtn tugtile-ed-find-b' }); setIcon(b.createSpan(), icon); b.setAttribute('aria-label', aria); b.addEventListener('mousedown', (e) => e.preventDefault()); b.addEventListener('click', fn); b.addEventListener('touchstart', (e) => { e.preventDefault(); fn(); }, { passive: false }); };
mkFb('chevron-up', t('findPrev'), () => findNext(true));
mkFb('chevron-down', t('findNext'), () => findNext(false));
mkFb('replace', t('replaceOne'), doReplace);
mkFb('replace-all', t('replaceAll'), doReplaceAll);
mkFb('x', t('cancel'), () => toggleFind(false));
findInp.addEventListener('input', updateN);
findInp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); findNext(!!e.shiftKey); } else if (e.key === 'Escape') { e.preventDefault(); toggleFind(false); } });
// Editor shortcuts: edit the text model directly, then applyEdit re-highlights + snapshots undo; mousedown preventDefault retains the caret
const wrap = (pre, post) => { const v = getText(), s = sel().start, e = sel().end; applyEdit(v.slice(0, s) + pre + v.slice(s, e) + post + v.slice(e), s + pre.length, e + pre.length); };
const lineStartOf = (v, pos) => v.lastIndexOf('\n', pos - 1) + 1;
// Line-prefix tools (bullet / number / check / quote). With a SELECTION it applies to EVERY line the selection
// touches; with just a caret it toggles that one line (caret kept). ordered=true → numbers: any "N. " counts as
// the prefix (so toggling off works on existing numbers), and adding renumbers the block 1..N. Blank lines are
// left alone. Toggles OFF only when every non-blank line already has it.
const togglePre = (pre, ordered) => {
const v = getText(), s = sel().start, e = sel().end;
const re = ordered ? /^\d+\.\s/ : null;
const has = (ln) => (re ? re.test(ln) : ln.startsWith(pre));
const strip = (ln) => (re ? ln.replace(re, '') : ln.slice(pre.length));
if (s === e) { // no selection → just the caret's line, caret preserved (original behaviour)
const ls = lineStartOf(v, s), ln = v.slice(ls), h = has(ln);
const cut = h ? (ln.length - strip(ln).length) : 0;
const nv = h ? v.slice(0, ls) + strip(ln) + v.slice(ls + ln.length) : v.slice(0, ls) + (ordered ? '1. ' : pre) + v.slice(ls);
applyEdit(nv, Math.max(ls, s + (h ? -cut : (ordered ? 3 : pre.length))));
return;
}
const firstLs = lineStartOf(v, s), lastLs = lineStartOf(v, e - 1);
const nlAfter = v.indexOf('\n', lastLs), blockEnd = nlAfter === -1 ? v.length : nlAfter;
const lines = v.slice(firstLs, blockEnd).split('\n');
const nonBlank = lines.filter((ln) => ln.trim() !== '');
const allHave = nonBlank.length > 0 && nonBlank.every(has);
let n = 0;
const out = lines.map((ln) => {
if (ln.trim() === '') return ln; // leave blank lines alone
if (allHave) return strip(ln); // every non-blank has it → remove
if (ordered) { n++; return n + '. ' + (re.test(ln) ? ln.replace(re, '') : ln); } // renumber the block 1..N
return has(ln) ? ln : pre + ln; // bullet/check/quote → add where missing
}).join('\n');
applyEdit(v.slice(0, firstLs) + out + v.slice(blockEnd), firstLs, firstLs + out.length); // keep the block selected
};
const setHeading = (hashes) => { const v = getText(), s = sel().start, ls = lineStartOf(v, s); const rest = v.slice(ls); const m = /^#{1,6}\s/.exec(rest); const cur = m ? m[0].length : 0; const repl = (m && m[0] === hashes) ? '' : hashes; const nv = v.slice(0, ls) + repl + rest.slice(cur); const np = Math.max(ls, s + (repl.length - cur)); applyEdit(nv, np); };
// Bind a toolbar button so a TAP fires the action (keeping editor focus) but a SWIPE scrolls the row instead.
// The old approach fired on touchstart+preventDefault, which was hair-trigger and blocked horizontal scrolling.
const bindTap = (b, run) => {
let tx = 0, ty = 0, moved = false;
b.addEventListener('mousedown', (e) => e.preventDefault()); // Mouse: prevents focus loss in the editor
b.addEventListener('click', run); // Mouse click path (suppressed on touch by the touchend below)
b.addEventListener('touchstart', (e) => { const t = e.touches[0]; tx = t.clientX; ty = t.clientY; moved = false; }, { passive: true });
b.addEventListener('touchmove', (e) => { const t = e.touches[0]; if (Math.abs(t.clientX - tx) > 10 || Math.abs(t.clientY - ty) > 10) moved = true; }, { passive: true });
b.addEventListener('touchend', (e) => { if (!moved) { e.preventDefault(); run(); } }, { passive: false }); // Fire only on a tap, not a scroll; preventDefault retains focus + blocks the synthetic click
};
const tbtn = (label, fn, icon, target, tip) => {
const b = (target || tools).createEl('button', { cls: 'tugtile-iconbtn tugtile-ed-tool' });
if (tip) b.setAttribute('aria-label', t(tip)); // hover tooltip + accessible name (every tool button — was missing)
if (icon) setIcon(b.createSpan(), icon); else b.textContent = label; // setIcon into a child <span>, NOT the <button> directly — iPad WebKit won't render an inline svg that's a direct button child
bindTap(b, () => { fn(); ed.focus(); }); // fn (applyEdit / undo / redo) already re-renders and refocuses
};
const insertTok = (tok) => { const v = getText(), s = sel().start; applyEdit(v.slice(0, s) + tok + v.slice(s), s + tok.length); };
// image/video toolbar buttons: the host's pick hook (opts.pickImage/pickVideo) returns a markdown token (the
// markup is uniform across surfaces; only WHERE the bytes live differs per platform). The picker is async and
// blurs the editor (file dialog / prompt) → capture the caret BEFORE, re-insert at that offset when it resolves.
const insertViaPick = (pick) => {
if (typeof pick !== 'function' || ed.getAttribute('contenteditable') === 'false') return;
const at = sel().start;
Promise.resolve(pick()).then((tok) => {
if (!tok || ed.getAttribute('contenteditable') === 'false') return;
const v = getText(); applyEdit(v.slice(0, at) + tok + v.slice(at), at + tok.length);
}).catch(() => {});
};
const runs = {
undo: () => { if (hi > 0) { hi--; restore(hist[hi]); } }, redo: () => { if (hi < hist.length - 1) { hi++; restore(hist[hi]); } },
h1: () => setHeading('# '), h2: () => setHeading('## '), h3: () => setHeading('### '),
bold: () => wrap('**', '**'), italic: () => wrap('*', '*'), strike: () => wrap('~~', '~~'),
bullet: () => togglePre('- '), number: () => togglePre('1. ', true), check: () => togglePre('- [ ] '), quote: () => togglePre('> '),
table: () => { const v = getText(), s = sel().start, ls = lineStartOf(v, s); const pre = (ls > 0 && v[ls - 1] !== '\n') ? '\n' : ''; const tbl = pre + '| | |\n| --- | --- |\n| | |\n'; applyEdit(v.slice(0, ls) + tbl + v.slice(ls), ls + pre.length + 2); }, // insert a starter 2×2 table; decorateTables grids it for in-place editing
code: () => wrap('`', '`'), link: () => wrap('[[', ']]'),
image: () => insertViaPick(opts.pickImage), video: () => insertViaPick(opts.pickVideo),
date: () => insertTok(host.dateTrigger || '@'), time: () => insertTok(host.timeTrigger || '@@'),
};
// Build the toolbar from EDITOR_TOOLS, honoring the per-button on/off settings; separators only appear between non-empty groups
const en = host.plugin.settings.editorTools || {};
// Phone rows: TOP = the bar's `tools` (search·undo·redo, the essentials); BOTTOM = tools2 (ALL format/insert
// tools in one horizontally-scrollable row). 'rowbreak' is now a plain separator (phone seps are hidden anyway).
let pendingSep = false;
EDITOR_TOOLS.forEach((tk) => {
if (tk === 'sep' || tk === 'rowbreak') { pendingSep = true; return; }
if (en[tk.key] === false) return; // honor per-button settings for ALL tools incl. search/undo/redo (so marktile can disable them); tugtile never sets these false → they stay on
if (tk.needs && typeof opts[tk.needs] !== 'function') return; // capability-gated tool (image/video): hide the button when the host didn't wire its hook → button exists IFF it works (no phantom)
const target = tk.fixed ? tools : (twoRow ? tools2 : tools); // fixed → bar; others → the second row on phone (the bar on desktop)
if (pendingSep && target.childElementCount > 0) target.createDiv({ cls: 'tugtile-ed-sep' }); // separators (hidden in the compact phone rows) only between non-empty groups
pendingSep = false;
if (tk.key === 'search') { // Special: toggles the find/replace bar (don't focus back to the textarea)
const b = target.createEl('button', { cls: 'tugtile-iconbtn tugtile-ed-tool' });
b.setAttribute('aria-label', t(tk.tip)); // "Find / replace"
setIcon(b.createSpan(), 'search'); // span child, not the button (iPad svg-in-button fix)
bindTap(b, () => toggleFind());
} else {
tbtn(tk.g, runs[tk.key], tk.icon, target, tk.tip);
}
});
// Pure-source mode: if every tool is disabled and there's no ✕/✓ (marktile), drop the whole toolbar.
if (!opts.onCancel && !opts.onSave && !tools.childElementCount && (!tools2 || !tools2.childElementCount)) { bar.remove(); if (tools2) tools2.remove(); }
// Smart Enter: continue a list on newline (- / * / 1. / - [ ]); a second Enter on an empty item exits the
// list. Runs through the proven applyEdit text-model (never touches the native Enter path), and only when
// the caret line is actually a list item — otherwise it returns false and the native newline happens.
const tryListContinue = () => {
const r = readSel(); if (!r || r.start !== r.end) return false; // collapsed caret only
const res = listContinuation(getText(), r.start);
if (!res) return false;
applyEdit(res.text, res.caret);
return true;
};
host.attachDatePicker(ta);
ta.addEventListener('keydown', (e) => {
if (host.isSubmitKey(e)) { e.preventDefault(); if (opts.onSubmit) opts.onSubmit(); return; }
// Undo/redo via OUR snapshot stack (same as the toolbar buttons). The editor rebuilds innerHTML on every
// re-highlight, which wipes the contenteditable's native undo — so Cmd+Z must NOT rely on the browser's
// native undo (that's why it silently stopped working). preventDefault blocks native; stopPropagation keeps
// the board's document-level ⌘Z handler out; the read-only guard mirrors the toolbar (locked = no edits).
if ((e.metaKey || e.ctrlKey) && (e.key || '').toLowerCase() === 'z') {
e.preventDefault(); e.stopPropagation();
if (ed.getAttribute('contenteditable') !== 'false') { if (e.shiftKey) runs.redo(); else runs.undo(); }
return;
}
if ((e.metaKey || e.ctrlKey) && (e.key || '').toLowerCase() === 'y') { // ⌘/Ctrl+Y = redo (Windows convention)
e.preventDefault(); e.stopPropagation();
if (ed.getAttribute('contenteditable') !== 'false') runs.redo();
return;
}
if (e.key === 'Escape' && opts.onEscape) { e.preventDefault(); opts.onEscape(); return; } // hosts without a cancel action (marktile) let Escape fall through naturally
if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229 && tryListContinue()) e.preventDefault(); // newline-producing Enter (submit already handled above) → continue the list if on one
if (e.key === 'Tab' && !e.isComposing) { // insert/remove a literal tab (contenteditable's default Tab just moves focus)
e.preventDefault();
const r = readSel(); if (!r) return;
const res = tabEdit(getText(), r.start, r.end, e.shiftKey);
if (res) applyEdit(res.text, res.caret);
}
});
// Only auto-focus a fresh (empty) card — then the keyboard is ready to type. For existing content, DON'T focus: leave it to the user's tap, so the caret lands where they tap instead of jumping to the end.
if (!orig) setTimeout(() => ta.focus(), 0);
// iOS virtual keyboard handling. Two defenses, because visualViewport doesn't reliably shrink in Obsidian's
// webview: (1) if it DOES, cap the sizing container to the visible height so the scroll region ends above the
// keyboard; (2) regardless, keep the caret line within the top ~45% of the scroll viewport — which is above
// where the keyboard sits even when the scroll area itself extends under it. The editor content carries a
// tall bottom padding (CSS) so there's always room to scroll the last real line up.
const vv = window.visualViewport;
const sizer = contentEl.closest('.tugtile-edit-modal-full'); // ONLY the full-screen card modal; null in marktile's pane (don't clamp a leaf to vv.height — that mis-sizes desktop split panes) (L4)
const keepCaretVisible = () => {
const n = locate(sel().start).node;
const lineEl = (n && n.nodeType === 3) ? n.parentElement : n;
if (!lineEl || !lineEl.getBoundingClientRect) return;
const lr = lineEl.getBoundingClientRect(), sr = scroll.getBoundingClientRect();
const upper = sr.top + sr.height * 0.45;
if (lr.bottom > upper) scroll.scrollTop += (lr.bottom - upper);
};
const applyVV = () => { if (vv && sizer) { sizer.style.height = vv.height + 'px'; sizer.style.maxHeight = vv.height + 'px'; } setTimeout(keepCaretVisible, 0); };
if (vv) { vv.addEventListener('resize', applyVV); vv.addEventListener('scroll', applyVV); applyVV(); }
ed.addEventListener('input', () => setTimeout(keepCaretVisible, 0)); // keep the caret above the keyboard as you type
return {
getValue: () => getText().replace(/\s+$/, ''),
rawValue: () => getText(),
setText: (text) => { if (ed.getAttribute('contenteditable') === 'false') return; render(text); pushHist(); }, // programmatic whole-document replace (TOC drag-reorder); pushHist → undoable + fires onChange (autosave). read-only guard like applyEdit.
isDirty: () => getText().replace(/\s+$/, '') !== orig.replace(/\s+$/, ''),
insertText: (text) => insertTok(text), // insert at the caret (used by image paste/drop); applyEdit's read-only guard applies
focus: () => ta.focus(),
destroy: () => { clearTimeout(syncT); if (vv) { vv.removeEventListener('resize', applyVV); vv.removeEventListener('scroll', applyVV); } if (sizer) { sizer.style.height = ''; sizer.style.maxHeight = ''; } },
};
}
// Full-screen, keyboard-safe source editor. Callback-driven (opts.text / opts.onSave / opts.onDiscard) so board cards, table rows, and the whole markdown file all reuse it.
// Host interface the editor needs from whatever embeds it — the board view, or a minimal marktile file host.
// This is the seam that lets the SAME editor open a kanban card OR a standalone .md file. A host duck-types
// this surface (BoardView already does; a marktile host no-ops the board-only parts):
// _editModalOpen (writable flag), freezeBoard(), unfreezeBoard(), closePopup(), consumePendingReload(),
// attachDatePicker(taAdapter), isSubmitKey(e)->bool, dateTrigger, timeTrigger, plugin.settings.editorTools
class TileEditModal extends Modal {
constructor(app, view, opts) { super(app); this.view = view; this.host = (opts && opts.host) || view; this._opts = opts || {}; }
onOpen() {
this.host._editModalOpen = true;
this.host.freezeBoard();
this.modalEl.addClass('tugtile-edit-modal-full');
// Tag the modal CONTAINER so the backdrop/alignment rules can target it directly instead of via
// .modal-container:has(.tugtile-edit-modal-full) — same timing as the modalEl class above, but no :has()
// selector invalidation. Guarded: the ejecta web shim's Modal may not expose containerEl.
if (this.containerEl) this.containerEl.addClass('tugtile-edit-host');
// Obsidian vault hooks (source path = the board file): image save/resolution. Computed up here so mountEditor's
// toolbar image/video buttons and equipEditor's paste handler share the same seam.
const app = this.app, srcPath = (this.view && this.view.file) ? this.view.file.path : '';
this._ctrl = mountEditor(this.contentEl, {
text: this._opts.text || '',
onSubmit: () => this._doClose('save'), onEscape: () => this._requestClose(), // keyboard: Enter saves, Escape cancels (the ✕/✓ buttons live in the control strip below)
onToc: () => { if (this._rig && this._rig.toc) this._rig.toc.toggle(); },
pickImage: () => pickVaultImage(app, srcPath), pickVideo: () => promptVideoEmbed(), // toolbar 🖼/🎞 → vault save / URL embed
}, this.host);
// Equip the same rig marktile uses → tugtile's big editor is literally marktile + the ✕/✓ buttons. Host hooks:
// Obsidian vault image resolution (source path = the board file) and the TOC's Sortable + mobile/anchor tuning.
this._rig = equipEditor({
mount: this.contentEl, ctrl: this._ctrl,
enabledModes: (this.host.plugin && this.host.plugin.settings && this.host.plugin.settings.modes) || {},
seasonedColor: !!(this.host.plugin && this.host.plugin.settings && this.host.plugin.settings.seasonedColor),
saveImage: (blob) => saveVaultImage(app, srcPath, blob), // paste/drop an image → vault attachment + ![[…]]
resolveSrc: (raw) => {
raw = String(raw).split('|')[0].trim();
if (/^(https?:|data:|app:)/i.test(raw)) return raw;
if (!/\.(png|jpe?g|gif|svg|webp|bmp|avif)$/i.test(raw.split('#')[0])) return null;
try { const f = app.metadataCache.getFirstLinkpathDest(raw, srcPath); return f ? app.vault.getResourcePath(f) : null; } catch (e) { return null; }
},
toc: {
Sortable: (typeof Sortable !== 'undefined' ? Sortable : (typeof window !== 'undefined' ? window.Sortable : null)),
labels: { title: t('mtToc'), empty: t('mtTocEmpty') },
onReorder: () => {}, anchorScroll: '.tugtile-ed-scroll',
sortableOptions: { delay: 180, delayOnTouchOnly: true, touchStartThreshold: 8, forceFallback: true, fallbackOnBody: true, fallbackTolerance: 4, dragClass: 'marktile-toc-item--drag' },
},
});
// Control strip in marktile's exact markup, prepended above the toolbar: [✕] · viewcycle · lock · [✓]. So the
// big editor reads as marktile + the modal's cancel/save. (marktile builds the same-looking strip in its header.)
const strip = createDiv({ cls: 'tugtile__ctlbar' });
this._ctl = buildEditorCtl(strip, {
cycleMode: () => { if (this._rig) this._rig.cycleMode(); },
currentMode: () => (this._rig ? this._rig.currentMode() : EDITOR_MODES[0]),
toggleLock: () => this._toggleLock(),
isLocked: () => !!this._locked,
brand: t('mtBrand'), brandLocked: t('mtBrandLocked'),
modeLabel: t('mtModeToggle'), lockLabel: t('mtLockToggle'),
onCancel: () => this._requestClose(), cancelLabel: t('cancel'),
onSave: () => this._doClose('save'), saveLabel: t('save'),
});
this.contentEl.prepend(strip);
}
_toggleLock() { this._locked = !this._locked; this._applyLock(); }
_applyLock() { const ed = this.contentEl.querySelector('.tugtile-ed-rich'); if (ed) ed.setAttribute('contenteditable', String(!this._locked)); this.contentEl.toggleClass('tugtile--locked', !!this._locked); }
_dirty() { return !!this._ctrl && this._ctrl.isDirty(); }
close() {
if (this._forceClose) { this._animateClose(); return; }
// Implicit closing (backdrop click or iOS virtual keyboard collapse) → ignored to prevent accidental close or save. Dismissed only via Save/Cancel/Escape.
}
_requestClose() { // Explicit cancel (Cancel button or Escape key)
if (!this._dirty()) { this._doClose('discard'); return; }
if (typeof window.confirm === 'function') { if (window.confirm(t('discardConfirm'))) this._doClose('discard'); /* otherwise do nothing */ }
else this._doClose('save'); // Mobile devices without confirm dialog → save changes to prevent data loss
}
_animateClose() {
if (this._closing) { return; } this._closing = true;
// Reverse exit animation: appends class to trigger pop-out animation, then closes. Saving has already finished in _doClose → _save; the animation is purely visual and does not delay saving.
this.modalEl.addClass('tugtile-ed-closing');
setTimeout(() => super.close(), 300); // Align with pop-out animation duration (0.32s)
}
_doClose(mode) {
this._forceClose = true;
if (mode === 'save') this._save();
else if (this._opts.onDiscard) this._opts.onDiscard(); // e.g. board discards a newly inserted empty card
this.close();
}
onClose() {
if (this._rig) { this._rig.destroy(); this._rig = null; }
if (this._ctrl) this._ctrl.destroy();
this.host._editModalOpen = false;
this.host.unfreezeBoard();
this.host.closePopup();
this.contentEl.empty();
this.host.consumePendingReload(); // Process external modifications deferred during modal editing
}
_save() {
if (this._done) return; this._done = true;
const v = this._ctrl ? this._ctrl.getValue() : '';
if (this._opts.onSave) this._opts.onSave(v);
}
}
// ───────────────────────────────────────────────────────────────────────────
// TABLE GRID (the "locked markers" in-grid markdown-table editor). Single source
// for tugtile/marktile (inlined) AND ejecta (tile-core emit). decorateTables(root,
// ctrl, gateClass) restyles contiguous |table| line-divs into an aligned grid and
// makes them editable in place; gateClass selects the host's "grid on" class —
// both marktile and ejecta use 'marktile-grid' (set in Seasoned + Rendered, dropped
// in Plain). The .tugtile-preview overlay then hides the pipes in Rendered, in CSS
// only. (gateClass defaults to 'tugtile-preview' for older callers.) textContent stays
// byte-identical → round-trip exact, no other core change. Was extracted from
// ejecta/core/editor/{table-align,table-view}.js; design notes live there.
// ───────────────────────────────────────────────────────────────────────────
// table-align — cheap markdown-table prettifier: pad cells with spaces so the pipes line up in a monospace
// editor. Stays single-layer (output is still valid markdown, edits in place, round-trips) — no CSS table, no
// widget. The one catch is CJK: 中文/日文/全形 are DOUBLE-width, so we measure DISPLAY width (east-asian-width),
// NOT code-point length, or the pipes drift. Pure string → runs in node + browser. See [[web-known-pitfalls]].
// East Asian Width: 2 for wide/fullwidth code points, else 1. Practical subset for zh/ja (not the full UAX#11
// table, but covers CJK ideographs, kana, hangul, and fullwidth forms/punctuation — what real content uses).
const WIDE = [
[0x1100, 0x115F], // Hangul Jamo
[0x2E80, 0x303E], // CJK radicals · Kangxi · CJK symbols & punctuation (、。「」…)
[0x3041, 0x33FF], // Hiragana · Katakana · enclosed CJK
[0x3400, 0x4DBF], // CJK Ext A
[0x4E00, 0x9FFF], // CJK Unified Ideographs
[0xA000, 0xA4CF], // Yi
[0xAC00, 0xD7A3], // Hangul syllables
[0xF900, 0xFAFF], // CJK compatibility ideographs
[0xFE30, 0xFE4F], // CJK compatibility forms
[0xFF00, 0xFF60], // Fullwidth forms (!?() …)
[0xFFE0, 0xFFE6], // Fullwidth signs
[0x20000, 0x3FFFD], // CJK Ext B+ (supplementary planes)
];
function charWidth(cp) { for (const [a, b] of WIDE) if (cp >= a && cp <= b) return 2; return 1; }
function dispWidth(s) { let w = 0; for (const ch of String(s)) w += charWidth(ch.codePointAt(0)); return w; }
const isTableLine = (l) => /^\s*\|.*\|\s*$/.test(l);
const splitRow = (line) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
const isSepRow = (cells) => cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c.trim()));
// Parse a contiguous block of table source lines into { header, align, body, ncol } for rendering a real
// <table>. Returns null if it isn't a valid table (2nd line must be the |---| separator). Used by the 編輯-mode
// table widget (table-view.js) — the browser then measures real glyph widths, so CJK columns align regardless
// of font (the fill-to-fit escape from the space-quantum problem).
function parseTable(lines) {
const rows = lines.map(splitRow);
if (rows.length < 2 || !isSepRow(rows[1])) return null;
const ncol = Math.max(...rows.map((r) => r.length));
const align = [];
for (let i = 0; i < ncol; i++) { const t = (rows[1][i] || '').trim(); const l = t.startsWith(':'), r = t.endsWith(':'); align[i] = (l && r) ? 'center' : r ? 'right' : 'left'; }
return { header: rows[0], align, body: rows.slice(2), ncol };
}
// Format ONE contiguous table block (source lines) → aligned lines; null if it isn't a real table.
function formatBlock(lines) {
if (lines.length < 2) return null;
const rows = lines.map(splitRow);
if (!isSepRow(rows[1])) return null; // 2nd line MUST be the |---| separator
const ncol = Math.max(...rows.map((r) => r.length));
const align = [];
for (let i = 0; i < ncol; i++) { const t = (rows[1][i] || '').trim(); const l = t.startsWith(':'), r = t.endsWith(':'); align[i] = (l && r) ? 'c' : r ? 'r' : 'l'; }
const w = [];
for (let i = 0; i < ncol; i++) { let mx = 3; rows.forEach((r, ri) => { if (ri !== 1) mx = Math.max(mx, dispWidth(r[i] || '')); }); w[i] = mx; }
const pad = (text, width, a) => {
const gap = width - dispWidth(text); if (gap <= 0) return text;
if (a === 'r') return ' '.repeat(gap) + text;
if (a === 'c') { const left = gap >> 1; return ' '.repeat(left) + text + ' '.repeat(gap - left); }
return text + ' '.repeat(gap);
};
return rows.map((r, ri) => {
if (ri === 1) return '| ' + w.map((width, i) => { const d = '-'.repeat(width); return align[i] === 'c' ? ':' + d.slice(2) + ':' : align[i] === 'r' ? d.slice(1) + ':' : d; }).join(' | ') + ' |';
return '| ' + w.map((width, i) => pad(r[i] || '', width, align[i])).join(' | ') + ' |';
});
}
// Re-align every contiguous markdown table block; non-table text is untouched. Idempotent.
function formatTables(md) {
const lines = String(md).replace(/\r\n/g, '\n').split('\n');
const out = [];
for (let i = 0; i < lines.length; i++) {
if (isTableLine(lines[i])) {
let j = i; const block = [];
while (j < lines.length && isTableLine(lines[j])) block.push(lines[j++]);
const fixed = formatBlock(block);
if (fixed) { out.push(...fixed); i = j - 1; continue; }
}
out.push(lines[i]);
}
return out.join('\n');
}
// table-view — in 編輯 mode, restyle a contiguous markdown table block so it LOOKS like a real grid with
// aligned columns — CJK included — and lets you EDIT INSIDE the grid safely, without touching marktile core.
//
// Why restyle the line <div>s IN PLACE (not insert a <table> widget): marktile's text model assumes the editor
// root's children ARE the lines (getText joins child textContent; caret math + lineCount walk the same children).
// So we keep the SAME line divs and only regroup each line's inner nodes (cells/pipes into spans) + CSS. Every
// line's textContent stays byte-identical → round-trip exact, no core change.
//
// In-grid editing rests on three legs (the "locked markers" design):
// 1. LOCKED MARKERS — every hidden pipe span is contenteditable=false (only in 編輯 mode), so the caret can't
// enter the syntax and a stray Backspace can't eat a pipe: the table structure is physically indestructible.
// A beforeinput guard additionally blocks deletions whose target range would cross a pipe / cell / row
// boundary. Typed characters flow into the cell's text node → the markdown is naturally correct.
// 2. SYNC RE-WRAP — marktile rebuilds a line's innerHTML after edits (wiping our spans). MutationObserver
// callbacks are microtasks that run BEFORE paint, so re-wrapping synchronously in the callback (with caret
// capture/restore around the surgery) is flicker-free by construction. No debounce.
// 3. TABLE KEYS, two dialects:
// · markdown 魂 — typing '|' in a cell SPLITS THE COLUMN there (the syntax IS the command; the split is
// propagated to every row so the table stays rectangular).
// · Word 遺毒 — Tab hops cells and GROWS A ROW from the last cell; Enter inserts a row below; right-click
// opens insert/delete column/row — the habits real (non-technical) operators arrive with.
// 調味/原味 show raw source: pipes visible and fully editable there (locks are preview-mode-only).
// Undo our wrapping: restore marktile's inline nodes + literal | text and drop our classes (idempotent base).
function unwrapLine(line) {
if (!line.querySelector('.ej-cell, .ej-pipe')) { line.classList.remove('ej-trow', 'ej-thead', 'ej-tsep'); return; }
line.querySelectorAll('.ej-pipe').forEach((p) => p.replaceWith(document.createTextNode('|')));
line.querySelectorAll('.ej-cell').forEach((c) => { while (c.firstChild) c.parentNode.insertBefore(c.firstChild, c); c.remove(); });
line.normalize();
line.classList.remove('ej-trow', 'ej-thead', 'ej-tsep');
}
// Group ONE highlighted line into cell/pipe spans, PRESERVING marktile's inline nodes (tg-b / tg-mk / tg-link)
// inside each cell — bold/italic render and their markers hide like everywhere else in 編輯 mode, textContent
// byte-identical. Splits at top-level '|' text only; tags each cell with its column alignment.
function wrapLine(line, aligns) {
unwrapLine(line);
const cells = []; let cur = [];
for (const node of [...line.childNodes]) {
if (node.nodeType === 3 && node.nodeValue.indexOf('|') >= 0) {
const segs = node.nodeValue.split('|');
for (let i = 0; i < segs.length; i++) { if (i > 0) { cells.push(cur); cur = []; } if (segs[i] !== '') cur.push(document.createTextNode(segs[i])); }
} else { cur.push(node); }
}
cells.push(cur);
const frag = document.createDocumentFragment();
cells.forEach((nodes, ci) => {
if (ci > 0) { const p = document.createElement('span'); p.className = 'ej-pipe'; p.textContent = '|'; frag.appendChild(p); }
if (ci === 0 || ci === cells.length - 1) { nodes.forEach((n) => frag.appendChild(n)); return; } // outer | … | border, no cell
const c = document.createElement('span'); c.className = 'ej-cell';
const a = aligns && aligns[ci - 1]; if (a && a !== 'left') c.dataset.a = a;
nodes.forEach((n) => c.appendChild(n)); frag.appendChild(c);
});
line.textContent = ''; // drop the leftover original text nodes (the '|'-bearing ones were copied, not moved)
line.appendChild(frag);
}
// caret char-offset within a line (textContent positions) — captured/restored around our DOM surgery
function caretOffset(line) {
const s = getSelection(); if (!s || !s.rangeCount) return null;
const r = s.getRangeAt(0); if (!line.contains(r.startContainer)) return null;
const pre = document.createRange(); pre.selectNodeContents(line); pre.setEnd(r.startContainer, r.startOffset);
return pre.toString().length;
}
function setCaret(line, off) {
let rem = off; const w = document.createTreeWalker(line, NodeFilter.SHOW_TEXT); let n;
while ((n = w.nextNode())) {
const len = n.nodeValue.length;
const inPipe = n.parentElement && n.parentElement.closest('.ej-pipe'); // locked+hidden — the caret can't live there;
if (rem <= len && !inPipe) { const r = document.createRange(); r.setStart(n, rem); r.collapse(true); const s = getSelection(); s.removeAllRanges(); s.addRange(r); return; }
if (rem <= len && inPipe) { rem = 0; continue; } // boundary inside a pipe → start of the NEXT visible node
rem -= len;
}
}
const nthPipe = (t, n) => { let c = -1; for (let k = 0; k < t.length; k++) { if (t[k] === '|') c++; if (c === n) return k; } return -1; };
function decorateTables(root, ctrl, gateClass) {
const inPreview = () => root.classList.contains(gateClass || 'tugtile-preview');
const lineOf = (node) => { if (!node || !root.contains(node)) return null; const el = node.nodeType === 3 ? node.parentElement : node; return el && el.closest ? el.closest('.tg-line') : null; };
const caretLineEl = () => { const s = getSelection(); return s && s.rangeCount ? lineOf(s.anchorNode) : null; };
const T = (k, fb) => { try { const s = (typeof t === 'function') ? t(k) : null; return (s != null && s !== k) ? s : fb; } catch (e) { return fb; } };
const setLocks = (line, on) => line.querySelectorAll('.ej-pipe').forEach((p) => { if (on) p.setAttribute('contenteditable', 'false'); else p.removeAttribute('contenteditable'); });
const blockRows = (line) => { let r = line; while (r.previousElementSibling && r.previousElementSibling.classList.contains('ej-trow')) r = r.previousElementSibling;
const rows = []; for (; r && r.classList.contains('ej-trow'); r = r.nextElementSibling) rows.push(r); return rows; };
const cellsOf = (rows) => rows.filter((x) => !x.classList.contains('ej-tsep')).flatMap((x) => [...x.querySelectorAll('.ej-cell')]);
// WebKit doesn't propagate a row's content-width change to the SIBLING rows of the anonymous table box (the
// header column stays stuck until you type in it). Cure: kick every row of the edited block out of table
// context and back (style-only, no DOM mutation → selection survives), forcing the anonymous table to be
// rebuilt with fresh column widths. Runs inside the MO microtask = before paint → invisible.
const relayout = (block) => {
block.forEach((l) => { l.style.display = 'block'; });
void block[0].offsetWidth; // flush layout while the rows are out of the table
block.forEach((l) => { l.style.display = ''; });
};
const scan = () => {
obs.disconnect();
try {
const cl = caretLineEl(); const clOff = cl ? caretOffset(cl) : null; let touchedCaret = false;
const lock = inPreview();
const lines = [...root.querySelectorAll('.tg-line')];
let i = 0;
while (i < lines.length) {
if (isTableLine(lines[i].textContent)) {
let j = i; const block = [];
while (j < lines.length && isTableLine(lines[j].textContent)) block.push(lines[j++]);
const parsed = parseTable(block.map((l) => l.textContent));
if (parsed) {
block.forEach((l, k) => {
if (!l.classList.contains('ej-trow')) { wrapLine(l, parsed.align); l.classList.add('ej-trow'); if (k === 0) l.classList.add('ej-thead'); if (k === 1) l.classList.add('ej-tsep'); if (l === cl) touchedCaret = true; }
setLocks(l, lock);
});
if (lock && block.indexOf(cl) >= 0) relayout(block); // typing in this block → resync sibling-row column widths
}
i = j; continue;
}
if (lines[i].classList.contains('ej-trow')) { if (lines[i] === cl) touchedCaret = true; unwrapLine(lines[i]); } // edited out of a table → restore
i++;
}
if (touchedCaret && cl && clOff != null) setCaret(cl, clOff); // our surgery moved the caret's nodes — put it back
} finally { obs.observe(root, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] }); }
};
// ---- whole-document table transforms (one ctrl.setText each = one undo step; caret restored after) ----
const docEdit = (mutate) => {
const all = [...root.querySelectorAll('.tg-line')];
const doc = ctrl.rawValue().split('\n');
const caret = mutate(all, doc) || {}; // mutate doc in place; returns {caretLine, caretOff}
ctrl.setText(doc.join('\n'));
if (caret.caretLine != null) { const fresh = root.querySelectorAll('.tg-line')[caret.caretLine]; if (fresh) setCaret(fresh, caret.caretOff || 0); }
};
// cell index of a char offset in a row's text (0-based; -1 = before the leading border pipe)
const cellIndexAt = (t, off) => { let ci = -1; for (let k = 0; k < off; k++) if (t[k] === '|') ci++; return ci; };
// insert an empty column so the NEW cell sits at index `at` in every row of `line`'s block
const insertColumn = (line, at, caretRow) => docEdit((all, doc) => {
const rows = blockRows(line); let out = null;
for (const r of rows) {
const ix = all.indexOf(r); const t = doc[ix];
const cell = r.classList.contains('ej-tsep') ? ' --- |' : ' |';
const open = nthPipe(t, at); // pipe that OPENS index `at`
const pos = open < 0 ? t.length : open + 1;
doc[ix] = t.slice(0, pos) + cell + t.slice(pos);
if (r === (caretRow || line)) out = { caretLine: ix, caretOff: pos + 1 };
}
return out;
});
const deleteColumn = (line, ci) => docEdit((all, doc) => {
const rows = blockRows(line); let out = null;
for (const r of rows) {
const ix = all.indexOf(r); const t = doc[ix];
const open = nthPipe(t, ci), close = nthPipe(t, ci + 1);
if (open < 0) continue;
doc[ix] = close < 0 ? t.slice(0, open + 1) : t.slice(0, open) + t.slice(close);
if (r === line) out = { caretLine: ix, caretOff: Math.max(1, open) };
}
return out;
});
const insertRow = (line, below) => docEdit((all, doc) => {
const rows = blockRows(line);
const parsed = parseTable(rows.map((l) => l.textContent)); if (!parsed) return null;
// from the header, "below" means below the |---| separator; "above" the header is not a table place
let anchor = line;
if (line.classList.contains('ej-thead')) anchor = below ? rows[1] : rows[0];
const ix = all.indexOf(anchor) + (below ? 1 : 0);
doc.splice(ix, 0, '|' + ' |'.repeat(parsed.ncol));
return { caretLine: ix, caretOff: 2 };
});
const deleteRow = (line) => docEdit((all, doc) => {
const ix = all.indexOf(line);
doc.splice(ix, 1);
return { caretLine: Math.max(0, ix - 1), caretOff: 2 };
});
// ---- Word-habit context menu: right-click a cell → insert/delete column/row ----
let menu = null;
const closeMenu = () => { if (menu) { menu.remove(); menu = null; } };
document.addEventListener('click', closeMenu, true);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenu(); }, true);
root.addEventListener('contextmenu', (e) => {
if (!inPreview() || !ctrl) return;
const cellEl = e.target && e.target.closest ? e.target.closest('.ej-cell') : null; if (!cellEl) return;
const line = cellEl.closest('.tg-line'); if (!line || !line.classList.contains('ej-trow')) return;
e.preventDefault(); closeMenu();
const ci = [...line.querySelectorAll('.ej-cell')].indexOf(cellEl);
const rows = blockRows(line);
const ncol = (parseTable(rows.map((l) => l.textContent)) || { ncol: 1 }).ncol;
const isHead = line.classList.contains('ej-thead');
menu = document.createElement('div'); menu.className = 'ej-tblmenu';
const item = (label, fn, disabled) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; if (disabled) b.disabled = true;
b.onmousedown = (ev) => ev.preventDefault();
b.onclick = (ev) => { ev.stopPropagation(); closeMenu(); fn(); }; menu.appendChild(b); return b; };
item(T('TBL_INS_COL_L', '在左方插入欄'), () => insertColumn(line, ci, line));
item(T('TBL_INS_COL_R', '在右方插入欄'), () => insertColumn(line, ci + 1, line));
menu.appendChild(document.createElement('hr'));
item(T('TBL_INS_ROW_A', '在上方插入列'), () => insertRow(line, false), isHead);
item(T('TBL_INS_ROW_B', '在下方插入列'), () => insertRow(line, true));
menu.appendChild(document.createElement('hr'));
item(T('TBL_DEL_COL', '刪除欄'), () => deleteColumn(line, ci), ncol <= 1);
item(T('TBL_DEL_ROW', '刪除列'), () => deleteRow(line), isHead);
menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px';
document.body.appendChild(menu);
const r = menu.getBoundingClientRect(); // keep on screen
if (r.right > innerWidth) menu.style.left = (innerWidth - r.width - 8) + 'px';
if (r.bottom > innerHeight) menu.style.top = (e.clientY - r.height) + 'px';
});
// ---- table keys (capture phase: marktile's Tab/Enter handlers must not see these) ----
root.addEventListener('keydown', (e) => {
if (!inPreview()) return;
const line = caretLineEl(); if (!line || !line.classList.contains('ej-trow')) return;
if (e.key === 'Tab') {
e.preventDefault(); e.stopPropagation();
const rows = blockRows(line);
const cells = cellsOf(rows);
const s = getSelection(); const cur = s.rangeCount ? (s.anchorNode.nodeType === 3 ? s.anchorNode.parentElement : s.anchorNode).closest('.ej-cell') : null;
const ix = cells.indexOf(cur);
if (!e.shiftKey && ix === cells.length - 1) { insertRow(rows[rows.length - 1], true); return; } // Word habit: Tab past the end grows a row
const next = cells[(ix < 0 ? 0 : ix + (e.shiftKey ? -1 : 1) + cells.length) % cells.length];
if (next) { const rg = document.createRange(); rg.selectNodeContents(next); rg.collapse(false); s.removeAllRanges(); s.addRange(rg); }
return;
}
if (e.key === 'Enter') { // Enter in a cell = NEW ROW below (a raw newline would split the row)
e.preventDefault(); e.stopPropagation();
if (ctrl) insertRow(line, true);
return;
}
}, true);
// ---- typing '|' in a cell = SPLIT THE COLUMN here (the syntax IS the command) ----
// In plain markdown a pipe splits that one row; in the grid we propagate the split to EVERY row of the
// block (empty cell after the same column; the |---| row gets a matching ---), keeping the table rectangular.
root.addEventListener('beforeinput', (e) => {
if (!inPreview() || !ctrl || e.inputType !== 'insertText' || e.data !== '|') return;
const line = caretLineEl(); if (!line || !line.classList.contains('ej-trow')) return;
const s = getSelection(); if (!s.rangeCount || !s.isCollapsed) return;
const anchorEl = s.anchorNode.nodeType === 3 ? s.anchorNode.parentElement : s.anchorNode;
if (!anchorEl.closest('.ej-cell')) return; // only inside a cell (not the outer | borders)
e.preventDefault(); e.stopPropagation();
const off = caretOffset(line); if (off == null) return;
const ci = cellIndexAt(line.textContent, off);
docEdit((all, doc) => {
const rows = blockRows(line); let out = null;
for (const r of rows) {
const ix = all.indexOf(r); const t = doc[ix];
if (r === line) { doc[ix] = t.slice(0, off) + '|' + t.slice(off); out = { caretLine: ix, caretOff: off + 1 }; continue; }
const close = nthPipe(t, ci + 1); // closing pipe of cell ci
const cell = r.classList.contains('ej-tsep') ? ' --- |' : ' |';
doc[ix] = close < 0 ? t + cell : t.slice(0, close + 1) + cell + t.slice(close + 1);
}
return out;
});
}, true);
// ---- deletion guard: block any delete whose target range would cross a pipe / cell / row boundary ----
root.addEventListener('beforeinput', (e) => {
if (!inPreview() || !e.inputType || !e.inputType.startsWith('delete')) return;
const line = caretLineEl(); const inTable = line && line.classList.contains('ej-trow');
const ranges = e.getTargetRanges ? e.getTargetRanges() : [];
if (!ranges.length) { return; }
for (const r of ranges) {
const sl = lineOf(r.startContainer), el = lineOf(r.endContainer);
if (!(sl && sl.classList.contains('ej-trow')) && !(el && el.classList.contains('ej-trow')) && !inTable) continue;
if (sl !== el) { e.preventDefault(); return; } // crossing a row boundary dissolves the table
const sC = (r.startContainer.nodeType === 3 ? r.startContainer.parentElement : r.startContainer);
const eC = (r.endContainer.nodeType === 3 ? r.endContainer.parentElement : r.endContainer);
if (sC.closest('.ej-pipe') || eC.closest('.ej-pipe')) { e.preventDefault(); return; } // touching a locked marker
const c1 = sC.closest('.ej-cell'), c2 = eC.closest('.ej-cell');
if (c1 !== c2) { e.preventDefault(); return; } // crossing a cell boundary
}
}, true);
const obs = new MutationObserver(scan); // microtask → re-wrap runs BEFORE paint → flicker-free by construction
scan();
return obs;
}
// Inline image thumbnails — show the picture beside its still-editable source line. The source text stays in the
// line (textContent untouched → round-trip exact, the markdown model is unaffected); a contenteditable=false <img>
// is appended as a sibling. resolveSrc(raw) maps the matched path/url to a real displayable src, or null to skip —
// ejecta passes web URLs through, marktile resolves vault paths (and returns null for non-images). marktile
// rebuilds the line divs on every keystroke, so a debounced MutationObserver re-applies. Shared by both hosts;
// this is the first step of Rendered "growing" — the same widget pattern later carries math / callouts.
function decorateImages(root, resolveSrc) {
const resolve = resolveSrc || ((u) => u);
const scan = () => {
root.querySelectorAll('.tg-line').forEach((line) => {
const txt = line.textContent || '';
const m = txt.match(/!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/) || txt.match(/!\[[^\]]*\]\(([^)]+)\)/) || txt.match(/<img[^>]+src=["']([^"']+)["']/);
const url = m ? resolve(m[1]) : null;
const cur = line.querySelector(':scope > img.ej-inlimg');
if (url) {
if (!cur || cur.dataset.u !== url) {
if (cur) cur.remove();
const im = document.createElement('img');
im.className = 'ej-inlimg'; im.contentEditable = 'false'; im.draggable = false; im.dataset.u = url; im.src = url;
line.appendChild(im); line.classList.add('ej-hasimg');
}
} else if (cur) { cur.remove(); line.classList.remove('ej-hasimg'); }
});
};
let t; const obs = new MutationObserver(() => { clearTimeout(t); t = setTimeout(scan, 80); });
obs.observe(root, { childList: true, subtree: true, characterData: true });
scan();
return obs;
}
// Table of contents — the ONE shared TOC for marktile (Obsidian) and ejecta (web). Lists H1H3 (tocHeadings),
// click to jump, drag to reorder whole sections (moveSection via Sortable). One visual (.marktile-toc* classes in
// the shared sheet). Hosts pass: mount (container), ctrl (editor controller), labels {title, empty} (each host
// resolves its own i18n), and optional Sortable (defaults to window.Sortable), onReorder (after a drag),
// onNavigate (after a click — e.g. marktile closes on phone), anchorScroll (selector whose offsetTop the panel
// pins below, for Obsidian's in-flow toolbar; ejecta anchors via CSS), sortableOptions (extra Sortable opts, e.g.
// marktile's mobile touch tuning). Returns { toggle, refresh, destroy }.
function wireToc(opts) {
const { mount, ctrl, labels, onReorder, onNavigate, anchorScroll, sortableOptions } = opts;
const Sortable = opts.Sortable || (typeof window !== 'undefined' ? window.Sortable : null);
let open = false, panel = null, debT = null, sortable = null;
const lab = labels || {};
const ensure = () => { if (panel && panel.isConnected) return; panel = document.createElement('div'); panel.className = 'marktile-toc'; mount.appendChild(panel); };
const killSortable = () => { if (sortable) { try { sortable.destroy(); } catch (e) {} sortable = null; } };
function build() {
const ed = mount.querySelector('.tugtile-ed-rich'); if (!ed || !panel) return;
if (anchorScroll) { const s = mount.querySelector(anchorScroll); if (s) panel.style.top = s.offsetTop + 'px'; } // pin below the in-flow toolbar (Obsidian)
killSortable();
panel.innerHTML = '';
const ti = document.createElement('div'); ti.className = 'marktile-toc-title'; ti.textContent = lab.title || ''; panel.appendChild(ti);
const list = document.createElement('div'); list.className = 'marktile-toc-list'; panel.appendChild(list);
const lineEls = ed.querySelectorAll('.tg-line'); const heads = tocHeadings(ctrl ? ctrl.rawValue() : '');
if (!heads.length) { const e = document.createElement('div'); e.className = 'marktile-toc-empty'; e.textContent = lab.empty || ''; list.appendChild(e); return; }
heads.forEach((h) => {
const it = document.createElement('div'); it.className = 'marktile-toc-item marktile-toc-l' + h.level; it.textContent = h.text || '—';
it.onclick = () => { const el = lineEls[h.line]; if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' }); if (onNavigate) onNavigate(); };
list.appendChild(it);
});
if (Sortable) try {
sortable = new Sortable(list, Object.assign({
draggable: '.marktile-toc-item', animation: 150,
ghostClass: 'marktile-toc-item--ghost', chosenClass: 'marktile-toc-item--chosen',
onEnd: (ev) => {
const cur = ctrl.rawValue(); const next = moveSection(cur, ev.oldIndex, ev.newIndex);
if (next !== cur && ctrl.setText) { ctrl.setText(next); if (onReorder) onReorder(); }
setTimeout(() => { if (open) build(); }, 0); // deferred: don't destroy the active Sortable from inside its own onEnd
},
}, sortableOptions || {}));
} catch (e) {}
}
return {
toggle(force) { open = (force === undefined) ? !open : !!force; if (open) { ensure(); build(); } mount.classList.toggle('marktile-toc-open', open); },
refresh() { if (!open) return; clearTimeout(debT); debT = setTimeout(() => { if (open) build(); }, 250); },
isOpen() { return open; },
destroy() { killSortable(); panel = null; open = false; },
};
}
// The editor "rig" — the full marktile experience any host equips on a mounted editor: the Seasoned/Rendered/Plain
// mode cycle (the class on `mount`), in-grid tables, inline images, and the TOC, all wired once. Hosts pass the
// mount + controller + their host-specific hooks (resolveSrc image resolver, toc options, which modes are enabled,
// an initialMode to restore across a rebuild) and render their OWN viewcycle button by calling cycleMode()/
// currentMode(). One rig — equipped by MarktileView, TileEditModal and the web editor alike — so "the editor" is
// literally one thing everywhere it appears.
const EDITOR_MODES = [
{ key: 'seasoned', cls: '', icon: 'square-m', name: 'mtModeSeasoned' },
{ key: 'rendered', cls: 'tugtile-preview', icon: 'square-pen', name: 'mtModeRendered' },
{ key: 'plain', cls: 'tugtile-plain', icon: 'square-code', name: 'mtModePlain' },
];
function equipEditor(opts) {
const { mount, ctrl, enabledModes, resolveSrc, toc, saveImage } = opts;
const onModes = () => { const md = enabledModes || {}; const on = EDITOR_MODES.filter((m) => md[m.key] !== false); return on.length ? on : EDITOR_MODES; };
let ix = 0;
if (opts.initialMode) { const i = onModes().findIndex((m) => m.key === opts.initialMode); if (i >= 0) ix = i; }
const current = () => { const e = onModes(); return e[ix % e.length]; };
const applyMode = () => {
const m = current();
mount.classList.toggle('marktile-grid', m.key !== 'plain'); // tables become a locked grid in Seasoned & Rendered
mount.classList.toggle('tugtile-preview', m.cls === 'tugtile-preview'); // Rendered hides the markers
mount.classList.toggle('tugtile-plain', m.cls === 'tugtile-plain'); // Plain = raw source
};
const tableObs = decorateTables(mount, ctrl, 'marktile-grid');
const imgObs = resolveSrc ? decorateImages(mount, resolveSrc) : null;
const tocCtl = toc ? wireToc(Object.assign({ mount, ctrl }, toc)) : null;
const paste = saveImage ? wireImagePaste(mount, ctrl, saveImage) : null;
mount.classList.toggle('marktile-palette-color', !!opts.seasonedColor); // Seasoned palette: accent (default) vs per-token colour; host passes the setting
applyMode();
return {
currentMode: current,
applyMode,
cycleMode() { ix = (ix + 1) % onModes().length; applyMode(); },
toc: tocCtl,
destroy() { try { tableObs.disconnect(); } catch (e) {} if (imgObs) { try { imgObs.disconnect(); } catch (e) {} } if (tocCtl) tocCtl.destroy(); if (paste) paste.destroy(); },
};
}
// The editor control strip — viewcycle + lock in marktile's exact markup (.tugtile-headerctl), with OPTIONAL
// cancel/save buttons on the ends. marktile builds its own strip inside the hijacked Obsidian header; TileEditModal
// builds THIS one at the top of the modal, so tugtile's big editor reads as "marktile + ✕/✓" — one look, two
// placements. ctl: { cycleMode, currentMode, toggleLock, isLocked, brand, brandLocked, modeLabel, lockLabel,
// onCancel, cancelLabel, onSave, saveLabel }. Returns { el, refresh }.
function buildEditorCtl(parent, ctl) {
const wrap = parent.createSpan({ cls: 'tugtile-headerctl' });
const iconBtn = (icon, label, fn) => { const b = wrap.createEl('button', { cls: 'tugtile-iconbtn' }); setIcon(b.createSpan(), icon); b.setAttribute('aria-label', label || ''); b.onclick = (e) => { e.preventDefault(); e.stopPropagation(); fn(); }; return b; };
if (ctl.onCancel) iconBtn('x', ctl.cancelLabel, ctl.onCancel); // ✕ on the left (modal only)
const vc = wrap.createSpan({ cls: 'tugtile-viewcycle' });
vc.setAttribute('role', 'button'); vc.setAttribute('aria-label', ctl.modeLabel || '');
vc.onclick = (e) => { e.preventDefault(); e.stopPropagation(); ctl.cycleMode(); refresh(); };
wrap.createSpan({ cls: 'tugtile-sep', text: '·' });
const lk = wrap.createSpan({ cls: 'tugtile-brand' });
lk.setAttribute('role', 'button'); lk.setAttribute('aria-label', ctl.lockLabel || '');
lk.onclick = (e) => { e.preventDefault(); e.stopPropagation(); ctl.toggleLock(); refresh(); };
if (ctl.onSave) iconBtn('check', ctl.saveLabel, ctl.onSave); // ✓ on the right (modal only)
function refresh() { // populate unconditionally — the strip is built detached (before prepend), so an isConnected gate would skip the first paint
vc.empty(); const m = ctl.currentMode(); setIcon(vc.createSpan({ cls: 'tugtile-viewcycle-icon' }), m.icon); vc.createSpan({ cls: 'tugtile-viewcycle-name', text: t(m.name) });
lk.empty(); const locked = ctl.isLocked && ctl.isLocked(); lk.createSpan({ cls: 'tugtile-brand-text', text: locked ? (ctl.brandLocked || '') : (ctl.brand || '') }); setIcon(lk.createSpan({ cls: 'tugtile-lock-icon' }), locked ? 'lock' : 'lock-open');
}
refresh();
return { el: wrap, refresh };
}
// Image paste/drop — without this, the browser shoves a base64 <img> into the contenteditable that getText() can't
// see, so a pasted picture silently vanishes on the next render. Here we intercept image paste/drop, hand the blob
// to the host's saveImage(blob) → markdown link (Obsidian: save to the vault attachment folder; ejecta: upload),
// and insert that link at the caret — then decorateImages shows the thumbnail. Returns { destroy }.
function wireImagePaste(root, ctrl, saveImage) {
const handle = async (files) => {
for (const f of files) {
if (!f || !f.type || f.type.indexOf('image/') !== 0) continue;
try { const link = await saveImage(f); if (link) ctrl.insertText(link); } catch (e) {}
}
};
const onPaste = (e) => {
const items = e.clipboardData && e.clipboardData.items; const files = [];
if (items) for (const it of items) { if (it.kind === 'file') { const f = it.getAsFile(); if (f && f.type && f.type.indexOf('image/') === 0) files.push(f); } }
if (files.length) { e.preventDefault(); e.stopPropagation(); handle(files); }
};
const onDrop = (e) => {
const fl = e.dataTransfer && e.dataTransfer.files; const imgs = fl ? [...fl].filter((f) => f.type && f.type.indexOf('image/') === 0) : [];
if (imgs.length) { e.preventDefault(); e.stopPropagation(); handle(imgs); }
};
root.addEventListener('paste', onPaste, true);
root.addEventListener('drop', onDrop, true);
return { destroy() { root.removeEventListener('paste', onPaste, true); root.removeEventListener('drop', onDrop, true); } };
}
// Obsidian saveImage hook — save a pasted/dropped blob into the vault's attachment folder and return an embed link.
// Used by both Obsidian hosts (marktile + modaltile); ejecta passes its own web-upload saveImage instead.
async function saveVaultImage(app, sourcePath, blob) {
const ext = (blob.type && blob.type.split('/')[1]) || 'png';
const stamp = (typeof Date !== 'undefined' && Date.now) ? Date.now() : Math.floor(performance.now());
const path = await app.fileManager.getAvailablePathForAttachment('pasted-' + stamp + '.' + ext, sourcePath || '');
const file = await app.vault.createBinary(path, await blob.arrayBuffer());
return '![[' + file.name + ']]';
}
// Obsidian image-insert hook for the toolbar 🖼 button: pick a file → save to the vault → return the SAME canonical
// embed paste/drop produces (![[name]]), so the button and paste are byte-identical. Hosts pass this as opts.pickImage.
function pickVaultImage(app, sourcePath) {
return new Promise((resolve) => {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none';
document.body.appendChild(inp);
inp.onchange = async () => {
const f = inp.files && inp.files[0]; inp.remove();
if (!f) return resolve(null);
try { resolve(await saveVaultImage(app, sourcePath, f)); } catch (e) { resolve(null); }
};
inp.click();
});
}
// Canonical video embed — ONE markup for every surface (Obsidian + web), so a post stays portable (publish from
// Obsidian → website renders the same). YouTube/Vimeo → responsive iframe; a direct file → <video>; else a plain link.
function videoEmbed(url) {
let m;
if ((m = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{11})/)))
return '<figure class="ej-video"><iframe src="https://www.youtube.com/embed/' + m[1] + '" title="video" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></figure>';
if ((m = url.match(/vimeo\.com\/(\d+)/)))
return '<figure class="ej-video"><iframe src="https://player.vimeo.com/video/' + m[1] + '" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></figure>';
if (/\.(mp4|webm|ogg)(\?|$)/i.test(url)) return '<figure class="ej-video"><video src="' + url + '" controls></video></figure>';
return '<p><a href="' + url + '">' + url + '</a></p>';
}
// Prompt for a video URL → canonical embed (toolbar 🎞 button). Shared by every host as opts.pickVideo.
function promptVideoEmbed() {
const url = (typeof prompt === 'function') ? prompt(t('edVideoPrompt')) : null;
const u = url && url.trim();
return u ? videoEmbed(u) : null;
}
const VIEW_TYPE = 'marktile-editor';
// marktile settings. editorTools: per-button on/off (missing key = on), same convention as tugtile — uncheck
// everything and the toolbar disappears. defaultEditor: opt-in to register marktile as the default .md editor
// (off by default, so Obsidian's native editor stays the no-surprise default).
const DEFAULTS = { editorTools: {}, defaultEditor: false, modes: {}, seasonedColor: false };
// The 3-mode cycle + its decorations live in the shared core (EDITOR_MODES + equipEditor, inlined). settings.modes
// gates which modes appear in the cycle (missing key = on, like editorTools); the picker keeps >=1 on.
// The editor's board-only hooks are no-ops; a .md file never "submits" on Enter (Enter is always a newline).
function makeFileHost(plugin) {
const tools = (plugin && plugin.settings && plugin.settings.editorTools) || {};
return {
_editModalOpen: false,
freezeBoard() {}, unfreezeBoard() {}, closePopup() {}, consumePendingReload() {},
attachDatePicker() {}, isSubmitKey() { return false; },
dateTrigger: '@', timeTrigger: '@@',
// date/time buttons insert tugtile's kanban-only syntax (meaningless in a plain note) → always off; everything
// else follows the user's toolbar settings.
plugin: { settings: { editorTools: Object.assign({}, tools, { date: false, time: false }) } },
};
}
// A real editor pane for a .md file. TextFileView handles file load/save; we mount the shared editor into it
// and autosave on change. A header action switches the leaf back to Obsidian's native markdown editor.
class MarktileView extends TextFileView {
constructor(leaf, plugin) { super(leaf); this.plugin = plugin; } // plugin ref → the editor reads its toolbar settings
getViewType() { return VIEW_TYPE; }
getDisplayText() { return this.file ? this.file.basename : 'marktile'; }
getIcon() { return 'square-m'; } // marktile's identity = an 'M' badge (= its Seasoned mode), like tugtile's board = gallery-vertical
async onOpen() {
// Header actions register RIGHT-TO-LEFT (addAction renders in reverse), and Obsidian's own ⋯ sits rightmost.
// Target order L→R: [→ tugtile] [→ Obsidian] [settings] [⋯]. So register settings → Obsidian → tugtile.
this.addAction('settings', t('mtSettings'), () => { try { this.app.setting.open(); this.app.setting.openTabById(this.plugin.manifest.id); } catch (e) {} });
this.addAction('file-text', t('mtBackToObsidian'), () => this.toObsidian()); // file-text = the conventional "native markdown" icon (Obsidian/Kanban/Excalidraw all use it)
// tile-family interop: hand this file off to tugtile (open it as a kanban board), if it's installed (leftmost)
if (this.app.plugins && this.app.plugins.enabledPlugins && this.app.plugins.enabledPlugins.has('tugtile')) {
this.addAction('gallery-vertical', t('mtToTugtile'), () => this.toTugtile());
}
this.watchHeaderTitle(); // take over the header title (clear the redundant filename, drop in the control strip) — see _buildHeaderCtl
}
// ── Header takeover, ported 1:1 from tugtile's BoardView (same CSS classes → identical look). The filename is
// redundant (the tab shows it), so it's cleared; the strip = viewcycle + brand/lock. Phone → a content-top
// .tugtile__ctlbar (built in setViewData); desktop → injected into the .view-header-title here.
// viewcycle cycles the view modes (cycleMode); brand/lock toggles read-only (toggleLock).
_headerTitleEl() { return this.containerEl ? this.containerEl.querySelector('.view-header-title') : null; }
decorateHeaderTitle() {
const el = this._headerTitleEl();
if (!el) return;
if (Platform.isPhone) { if (el.textContent !== '') el.textContent = ''; return; } // Phone: strip lives in the content ctlbar; keep the header title empty
if (el.querySelector('.tugtile-headerctl')) return; // Already built; skip (also prevents observer loops). Obsidian wipes it on updateHeader → we rebuild.
el.textContent = '';
this._buildHeaderCtl(el);
}
watchHeaderTitle() {
const el = this._headerTitleEl();
if (!el || this._titleObserver) return;
this._titleObserver = new MutationObserver(() => this.decorateHeaderTitle()); // Obsidian resets the title on updateHeader → re-apply
this._titleObserver.observe(el, { childList: true });
this.decorateHeaderTitle();
}
_buildHeaderCtl(parent) {
const wrap = parent.createSpan({ cls: 'tugtile-headerctl' });
const vc = wrap.createSpan({ cls: 'tugtile-viewcycle' }); // the view-mode cycle (Seasoned / Rendered / Plain)
vc.setAttribute('role', 'button');
vc.setAttribute('aria-label', t('mtModeToggle')); // cycles the view modes (NOT tugtile's board/table view switch)
vc.onclick = (e) => { e.preventDefault(); e.stopPropagation(); this.cycleMode(); };
this._viewCycleEl = vc;
wrap.createSpan({ cls: 'tugtile-sep', text: '·' });
const lk = wrap.createSpan({ cls: 'tugtile-brand' }); // brand suffix + lock icon = the read-only toggle
lk.setAttribute('role', 'button');
lk.setAttribute('aria-label', t('mtLockToggle')); // locks the EDITOR read-only (marktile has no board)
lk.onclick = (e) => { e.preventDefault(); e.stopPropagation(); this.toggleLock(); };
this._lockBtnEl = lk;
this.refreshCtl();
return wrap;
}
refreshCtl() {
const vc = this._viewCycleEl;
if (vc && vc.isConnected) {
vc.empty();
const _m = this._currentMode();
setIcon(vc.createSpan({ cls: 'tugtile-viewcycle-icon' }), _m.icon); // square-m Seasoned · square-pen Rendered · square-code Plain
vc.createSpan({ cls: 'tugtile-viewcycle-name', text: t(_m.name) });
}
const lk = this._lockBtnEl;
if (lk && lk.isConnected) {
lk.empty();
lk.createSpan({ cls: 'tugtile-brand-text', text: this._locked ? t('mtBrandLocked') : t('mtBrand') });
setIcon(lk.createSpan({ cls: 'tugtile-lock-icon' }), this._locked ? 'lock' : 'lock-open');
}
}
// The view cycle + its decorations are the shared core rig (equipEditor). The view only drives the button:
// cycleMode advances the rig and remembers the key (restored across a reload); _currentMode feeds refreshCtl.
_currentMode() {
return this._rig ? this._rig.currentMode() : EDITOR_MODES[0];
}
cycleMode() {
if (!this._rig) return;
this._rig.cycleMode();
this._modeKey = this._rig.currentMode().key;
this.refreshCtl();
}
// Lock: make the editor read-only. Obsidian has no save step (whatever you change IS saved), so this guards
// against stray edits/taps. Disables the contenteditable + (via CSS) the toolbar.
toggleLock() {
this._locked = !this._locked;
this._applyLock();
this.refreshCtl();
}
_applyLock() {
const ed = this.contentEl.querySelector('.tugtile-ed-rich');
if (ed) ed.setAttribute('contenteditable', String(!this._locked));
this.contentEl.toggleClass('tugtile--locked', !!this._locked);
}
// Table of contents: a toggle side panel of H1H3. The toggle button is added by the shared editor (onToc hook)
// in the ✕-close slot of marktile's ancestor, tugtile's card modal. Click a heading → scroll the editor to it.
// TOC = the shared core wireToc (one TOC for marktile + ejecta). marktile's extras ride as hooks: the panel pins
// below the in-flow toolbar (anchorScroll), the phone overlay closes after a jump (onNavigate), and the Sortable
// gets tugtile's mobile touch tuning (sortableOptions). the rig (this._rig) is (re)created in setViewData after the mount.
toggleToc(force) { if (this._rig && this._rig.toc) this._rig.toc.toggle(force); }
_refreshTocSoon() { if (this._rig && this._rig.toc) this._rig.toc.refresh(); } // shared wireToc no-ops when the panel is closed
toTugtile() {
if (!this.file) return;
this.leaf.setViewState({ type: 'tugtile-board', active: true, state: { file: this.file.path } });
}
getViewData() { return this._ctrl ? this._ctrl.rawValue() : this.data; }
setViewData(data, clear) {
this.data = data;
if (this._ctrl) { this._ctrl.destroy(); this._ctrl = null; }
const _tocWasOpen = this._rig && this._rig.toc ? this._rig.toc.isOpen() : false; // remember across the rebuild
if (this._rig) { this._rig.destroy(); this._rig = null; }
this.contentEl.empty();
this._ctrl = mountEditor(this.contentEl, { text: data, onChange: () => { this.requestSave(); this._refreshTocSoon(); }, onToc: () => this.toggleToc(), pickImage: () => pickVaultImage(this.app, this.file ? this.file.path : ''), pickVideo: () => promptVideoEmbed() }, makeFileHost(this.plugin));
// mountEditor() empties contentEl, so build the phone control strip AFTER it and prepend above the toolbar.
if (Platform.isPhone) { const ctl = createDiv({ cls: 'tugtile__ctlbar' }); this._buildHeaderCtl(ctl); this.contentEl.prepend(ctl); }
this.decorateHeaderTitle(); // desktop: inject into the header title; phone: keep the header filename cleared
this.contentEl.addClass('marktile-ed'); // scope: marktile IS a markdown editor → monospace font (not tugtile cards)
this._applyLock();
// Equip the shared editor rig (mode cycle + in-grid tables + inline images + TOC) on contentEl — the exact same
// rig tugtile's modal equips. Host hooks: Obsidian vault image resolution, and the TOC's mobile/anchor tuning.
this._rig = equipEditor({
mount: this.contentEl, ctrl: this._ctrl,
enabledModes: (this.plugin.settings && this.plugin.settings.modes) || {},
seasonedColor: !!(this.plugin.settings && this.plugin.settings.seasonedColor), // Seasoned palette: accent vs colour
initialMode: this._modeKey, // restore the current mode across a reload
saveImage: (blob) => saveVaultImage(this.app, this.file ? this.file.path : '', blob), // paste/drop an image → vault attachment + ![[…]]
resolveSrc: (raw) => {
raw = String(raw).split('|')[0].trim();
if (/^(https?:|data:|app:)/i.test(raw)) return raw;
if (!/\.(png|jpe?g|gif|svg|webp|bmp|avif)$/i.test(raw.split('#')[0])) return null;
try { const f = this.app.metadataCache.getFirstLinkpathDest(raw, this.file ? this.file.path : ''); return f ? this.app.vault.getResourcePath(f) : null; } catch (e) { return null; }
},
toc: {
Sortable: (typeof Sortable !== 'undefined' ? Sortable : undefined),
labels: { title: t('mtToc'), empty: t('mtTocEmpty') },
onReorder: () => this.requestSave(),
onNavigate: () => { if (Platform.isPhone) this.toggleToc(false); }, // phone overlay closes after a jump
anchorScroll: '.tugtile-ed-scroll', // pin the panel below the in-flow toolbar
sortableOptions: { delay: 180, delayOnTouchOnly: true, touchStartThreshold: 8, forceFallback: true, fallbackOnBody: true, fallbackTolerance: 4, dragClass: 'marktile-toc-item--drag' },
},
});
this.refreshCtl(); // viewcycle button reflects the restored mode
if (_tocWasOpen) this._rig.toc.toggle(true); // restore the TOC across a reload
}
clear() {
if (this._ctrl) { this._ctrl.destroy(); this._ctrl = null; }
if (this._rig) { this._rig.destroy(); this._rig = null; }
this.contentEl.empty();
this.data = '';
}
async onClose() { if (this._rig) { this._rig.destroy(); this._rig = null; } if (this._titleObserver) { this._titleObserver.disconnect(); this._titleObserver = null; } if (this._ctrl) { this._ctrl.destroy(); this._ctrl = null; } }
toObsidian() {
if (!this.file) return;
// tugtile globally hooks setViewState to reclaim board files as boards, so a plain setViewState('markdown')
// on a board file bounces straight back to tugtile. Use tugtile's sanctioned escape-hatch API when present;
// fall back to a direct switch when tugtile isn't installed (then there's no hook to dodge).
const tg = this.app.plugins && this.app.plugins.plugins && this.app.plugins.plugins['tugtile'];
if (tg && typeof tg.openAsObsidian === 'function') { tg.openAsObsidian(this.leaf); return; }
this.leaf.setViewState({ type: 'markdown', active: true, state: { file: this.file.path, mode: 'source' } });
}
}
// Settings tab: toolbar-button pickers (same style as tugtile; uncheck all → no toolbar) + the default-editor opt-in.
class MarktileSettingTab extends PluginSettingTab {
constructor(app, plugin) { super(app, plugin); this.plugin = plugin; }
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h3', { text: t('mtSettingsTitle') });
containerEl.createEl('p', { cls: 'setting-item-description', text: t('mtSettingsDesc') });
const toolPicker = (name, desc, filter) => new Setting(containerEl).setName(name).setDesc(desc).then((s) => {
s.controlEl.addClass('tugtile-tools-pick');
const et = this.plugin.settings.editorTools || (this.plugin.settings.editorTools = {});
EDITOR_TOOLS.forEach((tk) => {
if (tk === 'sep' || tk === 'rowbreak' || !filter(tk)) return;
const lbl = s.controlEl.createEl('label', { cls: 'tugtile-tool-chk' });
const cb = lbl.createEl('input', { type: 'checkbox' });
cb.checked = et[tk.key] !== false;
const glyph = lbl.createSpan({ cls: 'tugtile-tool-chk-i' });
if (tk.icon) setIcon(glyph, tk.icon); else glyph.textContent = tk.g; // same icon the toolbar uses
cb.onchange = async () => { et[tk.key] = cb.checked; await this.plugin.saveSettings(); };
});
});
toolPicker(t('mtEssentialTools'), t('mtEssentialToolsDesc'), (tk) => tk.fixed); // search / undo / redo
toolPicker(t('gFormatTools'), t('gFormatToolsDesc'), (tk) => tk.cat === 'format'); // headings / bold / italic / strike
toolPicker(t('gBlockTools'), t('gBlockToolsDesc'), (tk) => tk.cat === 'block'); // lists / check / quote / table
toolPicker(t('gInsertTools'), t('mtInsertToolsDesc'), (tk) => tk.cat === 'insert' && tk.key !== 'date' && tk.key !== 'time'); // code / link (date/time are board-only)
new Setting(containerEl).setName(t('mtModesPick')).setDesc(t('mtModesPickDesc')).then((s) => {
s.controlEl.addClass('tugtile-tools-pick');
const md = this.plugin.settings.modes || (this.plugin.settings.modes = {});
EDITOR_MODES.forEach((m) => {
const lbl = s.controlEl.createEl('label', { cls: 'tugtile-tool-chk' });
const cb = lbl.createEl('input', { type: 'checkbox' });
cb.checked = md[m.key] !== false;
setIcon(lbl.createSpan({ cls: 'tugtile-tool-chk-i' }), m.icon);
lbl.createSpan({ text: t(m.name) });
cb.onchange = async () => {
const willOn = EDITOR_MODES.filter((x) => (x.key === m.key ? cb.checked : md[x.key] !== false));
if (!willOn.length) { cb.checked = true; new Notice(t('mtModesMinOne')); return; } // keep at least one mode in the cycle
md[m.key] = cb.checked; await this.plugin.saveSettings(); new Notice(t('mtReloadRequired'));
};
});
});
new Setting(containerEl).setName(t('mtSeasonedColor')).setDesc(t('mtSeasonedColorDesc'))
.addToggle((tg) => tg.setValue(!!this.plugin.settings.seasonedColor).onChange(async (v) => {
this.plugin.settings.seasonedColor = v; await this.plugin.saveSettings(); new Notice(t('mtReloadRequired'));
}));
new Setting(containerEl).setName(t('mtDefaultEditor')).setDesc(t('mtDefaultEditorDesc'))
.addToggle((tg) => tg.setValue(this.plugin.settings.defaultEditor).onChange(async (v) => {
this.plugin.settings.defaultEditor = v; await this.plugin.saveSettings(); new Notice(t('mtReloadRequired'));
}));
// tile family cross-discovery: tell marktile users tugtile exists (even if not installed)
const hasTg = !!(this.app.plugins && this.app.plugins.enabledPlugins && this.app.plugins.enabledPlugins.has('tugtile'));
const fam = new Setting(containerEl).setName(t('familyTugtile')).setDesc(hasTg ? t('familyHave') : t('familyTugtileDesc'));
if (!hasTg) fam.addButton((b) => b.setButtonText(t('familyGet')).onClick(() => window.open('obsidian://show-plugin?id=tugtile')));
}
}
module.exports = class MarktilePlugin extends Plugin {
async onload() {
await this.loadSettings();
this._mdBtns = []; // injected native-header buttons, tracked so we can remove them on unload
this.registerView(VIEW_TYPE, (leaf) => new MarktileView(leaf, this));
this.addSettingTab(new MarktileSettingTab(this.app, this));
// Opt-in (off by default): make marktile the default editor for .md. Board files still open here too — hop to
// tugtile with the gallery-vertical button. Toggling needs an Obsidian reload to take effect.
if (this.settings.defaultEditor) { try { this.registerExtensions(['md'], VIEW_TYPE); } catch (e) {} }
this.addRibbonIcon('square-m', t('mtRibbon'), () => this.openActiveInMarktile());
this.addCommand({
id: 'open-in-marktile',
name: t('mtOpenCmd'),
checkCallback: (checking) => {
const f = this.app.workspace.getActiveFile();
const ok = !!(f && f.extension === 'md');
if (ok && !checking) this.openActiveInMarktile();
return ok;
},
});
// Put an "open in marktile" button on every native markdown editor's header (the Obsidian → marktile hop)
this.registerEvent(this.app.workspace.on('active-leaf-change', (leaf) => this.injectMdButton(leaf)));
// Also inject into markdown panes already open when marktile loads (active-leaf-change won't fire for them)
this.app.workspace.onLayoutReady(() => this.app.workspace.getLeavesOfType('markdown').forEach((l) => this.injectMdButton(l)));
}
onunload() {
// Per Obsidian plugin guidelines: do NOT detachLeavesOfType here — Obsidian reinitializes
// open leaves at their original position on update; detaching ourselves causes problems.
(this._mdBtns || []).forEach(({ view, el }) => { if (el) el.remove(); if (view) delete view._mtBtn; }); // Remove the buttons we injected into native headers
this._mdBtns = [];
}
async loadSettings() { this.settings = Object.assign({}, DEFAULTS, await this.loadData()); }
async saveSettings() { await this.saveData(this.settings); }
injectMdButton(leaf) {
this._mdBtns = (this._mdBtns || []).filter(({ el }) => el && el.isConnected); // prune entries for closed leaves so dead MarkdownView refs can be GC'd (L3)
const v = leaf && leaf.view;
if (!v || typeof v.getViewType !== 'function' || v.getViewType() !== 'markdown' || v._mtBtn) return;
try {
const el = v.addAction('square-m', t('mtRibbon'), () => {
const f = v.file;
if (f) leaf.setViewState({ type: VIEW_TYPE, active: true, state: { file: f.path } });
});
v._mtBtn = el;
this._mdBtns.push({ view: v, el });
} catch (e) { /* header injection is best-effort; the command/ribbon are the reliable entry points */ }
}
openActiveInMarktile() {
const f = this.app.workspace.getActiveFile();
if (!f || f.extension !== 'md') { new Notice(t('mtNoFile')); return; }
const leaf = this.app.workspace.getLeaf(false);
leaf.setViewState({ type: VIEW_TYPE, active: true, state: { file: f.path } });
}
};