2026-06-10 09:15:42 +00:00
'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 ;
} ) ( ) ;
/ * m a r k t i l e — t h e t i l e - f a m i l y s t a n d a l o n e e d i t o r . O p e n s a n y . m d n o t e i n t h e S A M E w o r l d - c l a s s e d i t o r a s
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' ;
} ) ( ) ;
2026-06-14 12:21:47 +00:00
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 tugtile ’ s own format ? It will no longer open in obsidian - kanban , and kanban - only settings will be d
2026-06-10 09:15:42 +00:00
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 ;
}
/ * t i l e - f a m i l y s h a r e d e d i t o r c o r e — t h e S I N G L E s o u r c e o f t h e e d i t o r e n g i n e u s e d b y B O T H p l u g i n s .
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)
2026-06-12 07:23:09 +00:00
// 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' ,
2026-06-13 12:10:12 +00:00
{ 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.
2026-06-14 02:31:09 +00:00
{ 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' ,
2026-06-10 09:15:42 +00:00
{ 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 , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ; }
// 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' ;
2026-06-13 12:30:49 +00:00
// 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' ;
2026-06-10 09:15:42 +00:00
else if ( /^\s*[-*]\s/ . test ( line ) ) cls += ' tg-li' ;
2026-06-11 05:20:09 +00:00
// Each syntax marker (## , > , - , [ ], **, *, ~~, `, [[ ]], @{}) 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 > (the quote marker), so match that form.
2026-06-10 09:15:42 +00:00
const h = escHtml ( line )
2026-06-13 12:30:49 +00:00
. 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
2026-06-11 05:20:09 +00:00
. replace ( /^(>\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>' )
2026-06-10 09:15:42 +00:00
. 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
}
2026-06-13 03:01:42 +00:00
// 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 ; } }
2026-06-20 08:57:10 +00:00
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
2026-06-13 03:01:42 +00:00
}
return changed ? lines . join ( '\n' ) : v ;
}
2026-06-10 09:15:42 +00:00
// 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 H1– H3 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 ;
} ;
2026-06-13 03:01:42 +00:00
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
2026-06-10 09:15:42 +00:00
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 ;
2026-06-13 03:01:42 +00:00
// 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
} ;
2026-06-10 09:15:42 +00:00
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 ) ; } ;
2026-06-13 12:10:12 +00:00
// 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 ( ( ) => { } ) ;
} ;
2026-06-10 09:15:42 +00:00
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 ( '~~' , '~~' ) ,
2026-06-13 03:01:42 +00:00
bullet : ( ) => togglePre ( '- ' ) , number : ( ) => togglePre ( '1. ' , true ) , check : ( ) => togglePre ( '- [ ] ' ) , quote : ( ) => togglePre ( '> ' ) ,
2026-06-12 07:23:09 +00:00
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
2026-06-10 09:15:42 +00:00
code : ( ) => wrap ( '`' , '`' ) , link : ( ) => wrap ( '[[' , ']]' ) ,
2026-06-13 12:10:12 +00:00
image : ( ) => insertViaPick ( opts . pickImage ) , video : ( ) => insertViaPick ( opts . pickVideo ) ,
2026-06-10 09:15:42 +00:00
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
2026-06-13 12:10:12 +00:00
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)
2026-06-10 09:15:42 +00:00
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 ; }
2026-06-11 05:20:09 +00:00
// 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 ;
}
2026-06-10 09:15:42 +00:00
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+$/ , '' ) ,
2026-06-13 03:01:42 +00:00
insertText : ( text ) => insertTok ( text ) , // insert at the caret (used by image paste/drop); applyEdit's read-only guard applies
2026-06-10 09:15:42 +00:00
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' ) ;
2026-06-20 08:57:10 +00:00
// 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' ) ;
2026-06-13 12:10:12 +00:00
// 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 : '' ;
2026-06-12 07:23:09 +00:00
this . _ctrl = mountEditor ( this . contentEl , {
text : this . _opts . text || '' ,
2026-06-13 03:01:42 +00:00
onSubmit : ( ) => this . _doClose ( 'save' ) , onEscape : ( ) => this . _requestClose ( ) , // keyboard: Enter saves, Escape cancels (the ✕/✓ buttons live in the control strip below)
2026-06-12 07:23:09 +00:00
onToc : ( ) => { if ( this . _rig && this . _rig . toc ) this . _rig . toc . toggle ( ) ; } ,
2026-06-13 12:10:12 +00:00
pickImage : ( ) => pickVaultImage ( app , srcPath ) , pickVideo : ( ) => promptVideoEmbed ( ) , // toolbar 🖼/🎞 → vault save / URL embed
2026-06-12 07:23:09 +00:00
} , 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 ) || { } ,
2026-06-13 12:10:12 +00:00
seasonedColor : ! ! ( this . host . plugin && this . host . plugin . settings && this . host . plugin . settings . seasonedColor ) ,
2026-06-13 03:01:42 +00:00
saveImage : ( blob ) => saveVaultImage ( app , srcPath , blob ) , // paste/drop an image → vault attachment + ![[…]]
2026-06-12 07:23:09 +00:00
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' } ,
} ,
} ) ;
2026-06-13 03:01:42 +00:00
// 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 ) ;
2026-06-10 09:15:42 +00:00
}
2026-06-13 03:01:42 +00:00
_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 ) ; }
2026-06-10 09:15:42 +00:00
_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 ( ) {
2026-06-12 07:23:09 +00:00
if ( this . _rig ) { this . _rig . destroy ( ) ; this . _rig = null ; }
2026-06-10 09:15:42 +00:00
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 ) ;
}
}
2026-06-12 07:23:09 +00:00
// ───────────────────────────────────────────────────────────────────────────
// 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 H1– H3 (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 ) {
2026-06-13 03:01:42 +00:00
const { mount , ctrl , enabledModes , resolveSrc , toc , saveImage } = opts ;
2026-06-12 07:23:09 +00:00
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 ;
2026-06-13 03:01:42 +00:00
const paste = saveImage ? wireImagePaste ( mount , ctrl , saveImage ) : null ;
2026-06-13 12:10:12 +00:00
mount . classList . toggle ( 'marktile-palette-color' , ! ! opts . seasonedColor ) ; // Seasoned palette: accent (default) vs per-token colour; host passes the setting
2026-06-12 07:23:09 +00:00
applyMode ( ) ;
return {
currentMode : current ,
applyMode ,
cycleMode ( ) { ix = ( ix + 1 ) % onModes ( ) . length ; applyMode ( ) ; } ,
toc : tocCtl ,
2026-06-13 03:01:42 +00:00
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 ) ; }
2026-06-12 07:23:09 +00:00
} ;
2026-06-13 03:01:42 +00:00
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 + ']]' ;
2026-06-12 07:23:09 +00:00
}
2026-06-13 12:10:12 +00:00
// 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 ;
}
2026-06-12 07:23:09 +00:00
2026-06-10 09:15:42 +00:00
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).
2026-06-13 12:10:12 +00:00
const DEFAULTS = { editorTools : { } , defaultEditor : false , modes : { } , seasonedColor : false } ;
2026-06-12 07:23:09 +00:00
// 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.
2026-06-10 09:15:42 +00:00
// 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' ; }
2026-06-14 02:31:09 +00:00
getIcon ( ) { return 'square-m' ; } // marktile's identity = an 'M' badge (= its Seasoned mode), like tugtile's board = gallery-vertical
2026-06-10 09:15:42 +00:00
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' ) ) {
2026-06-14 02:31:09 +00:00
this . addAction ( 'gallery-vertical' , t ( 'mtToTugtile' ) , ( ) => this . toTugtile ( ) ) ;
2026-06-10 09:15:42 +00:00
}
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.
2026-06-13 03:01:42 +00:00
// viewcycle cycles the view modes (cycleMode); brand/lock toggles read-only (toggleLock).
2026-06-10 09:15:42 +00:00
_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' } ) ;
2026-06-13 03:01:42 +00:00
const vc = wrap . createSpan ( { cls : 'tugtile-viewcycle' } ) ; // the view-mode cycle (Seasoned / Rendered / Plain)
2026-06-10 09:15:42 +00:00
vc . setAttribute ( 'role' , 'button' ) ;
2026-06-13 03:01:42 +00:00
vc . setAttribute ( 'aria-label' , t ( 'mtModeToggle' ) ) ; // cycles the view modes (NOT tugtile's board/table view switch)
2026-06-12 07:23:09 +00:00
vc . onclick = ( e ) => { e . preventDefault ( ) ; e . stopPropagation ( ) ; this . cycleMode ( ) ; } ;
2026-06-10 09:15:42 +00:00
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 ( ) ;
2026-06-12 07:23:09 +00:00
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 ) } ) ;
2026-06-10 09:15:42 +00:00
}
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' ) ;
}
}
2026-06-12 07:23:09 +00:00
// 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 ;
2026-06-10 09:15:42 +00:00
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 H1– H3. 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.
2026-06-12 07:23:09 +00:00
// 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
2026-06-13 03:01:42 +00:00
// gets tugtile's mobile touch tuning (sortableOptions). the rig (this._rig) is (re)created in setViewData after the mount.
2026-06-12 07:23:09 +00:00
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
2026-06-10 09:15:42 +00:00
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 ; }
2026-06-12 07:23:09 +00:00
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 ; }
2026-06-10 09:15:42 +00:00
this . contentEl . empty ( ) ;
2026-06-13 12:10:12 +00:00
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 ) ) ;
2026-06-10 09:15:42 +00:00
// 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
2026-06-12 07:23:09 +00:00
this . contentEl . addClass ( 'marktile-ed' ) ; // scope: marktile IS a markdown editor → monospace font (not tugtile cards)
2026-06-10 09:15:42 +00:00
this . _applyLock ( ) ;
2026-06-12 07:23:09 +00:00
// 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 ) || { } ,
2026-06-13 12:10:12 +00:00
seasonedColor : ! ! ( this . plugin . settings && this . plugin . settings . seasonedColor ) , // Seasoned palette: accent vs colour
2026-06-12 07:23:09 +00:00
initialMode : this . _modeKey , // restore the current mode across a reload
2026-06-13 03:01:42 +00:00
saveImage : ( blob ) => saveVaultImage ( this . app , this . file ? this . file . path : '' , blob ) , // paste/drop an image → vault attachment + ![[…]]
2026-06-12 07:23:09 +00:00
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
2026-06-10 09:15:42 +00:00
}
clear ( ) {
if ( this . _ctrl ) { this . _ctrl . destroy ( ) ; this . _ctrl = null ; }
2026-06-12 07:23:09 +00:00
if ( this . _rig ) { this . _rig . destroy ( ) ; this . _rig = null ; }
2026-06-10 09:15:42 +00:00
this . contentEl . empty ( ) ;
this . data = '' ;
}
2026-06-12 07:23:09 +00:00
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 ; } }
2026-06-10 09:15:42 +00:00
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
2026-06-12 07:23:09 +00:00
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
2026-06-10 09:15:42 +00:00
toolPicker ( t ( 'gInsertTools' ) , t ( 'mtInsertToolsDesc' ) , ( tk ) => tk . cat === 'insert' && tk . key !== 'date' && tk . key !== 'time' ) ; // code / link (date/time are board-only)
2026-06-12 07:23:09 +00:00
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' ) ) ;
} ;
} ) ;
} ) ;
2026-06-13 12:10:12 +00:00
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' ) ) ;
} ) ) ;
2026-06-10 09:15:42 +00:00
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' ) ) ;
} ) ) ;
2026-06-14 09:58:50 +00:00
// 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' ) ) ) ;
2026-06-10 09:15:42 +00:00
}
}
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
2026-06-14 02:31:09 +00:00
// tugtile with the gallery-vertical button. Toggling needs an Obsidian reload to take effect.
2026-06-10 09:15:42 +00:00
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 ( ) {
2026-06-11 13:39:58 +00:00
// Per Obsidian plugin guidelines: do NOT detachLeavesOfType here — Obsidian reinitializes
// open leaves at their original position on update; detaching ourselves causes problems.
2026-06-10 09:15:42 +00:00
( 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 } } ) ;
}
} ;