From 7c28566d21fa9f27751edf8922543e6e1ce4fece Mon Sep 17 00:00:00 2001 From: mixflavor <31689802+mixflavor@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:01:31 +0900 Subject: [PATCH] =?UTF-8?q?tugtile=200.0.214=20=E2=80=94=20sync=20latest?= =?UTF-8?q?=20editor=20build=20from=20the=20monorepo=20(incl.=20squircle?= =?UTF-8?q?=20corners)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit squircle continuous-curvature corners are a progressive enhancement: Chromium supports them, while Safari/WebKit and Firefox gracefully degrade to border-radius, with zero visual regression. Co-Authored-By: chodaict Co-Authored-By: Claude Opus 4.8 (1M context) --- main.js | 176 ++++++++++++++++++++++++++++++++++++++++++++------ manifest.json | 2 +- styles.css | 22 +++++++ versions.json | 2 +- 4 files changed, 180 insertions(+), 22 deletions(-) diff --git a/main.js b/main.js index e60896e..41cd337 100644 --- a/main.js +++ b/main.js @@ -39,6 +39,10 @@ function t(key, ...args) { const LANE_WIDTHS = { narrow: 260, medium: 300, wide: 360 }; // Lane width presets (px) const TABLE_PADS = { narrow: 5, medium: 9, wide: 14 }; // Table row vertical-padding presets (px) — "table density" +// Shared touch-drag tuning for EVERY draggable (lanes + tiles): long-press to start (delay), and tolerate a little +// finger tremor first (touchStartThreshold) so a tap/long-press to rename a lane isn't hijacked into a drag. +// One source → lanes and tiles always feel the same. (TOC drag in the editor core carries its own copy.) +const DRAG_TOUCH = { delay: 180, delayOnTouchOnly: true, touchStartThreshold: 8, forceFallback: true, fallbackOnBody: true, fallbackTolerance: 4 }; // Full-screen editor toolbar buttons (order + groups). 'sep' marks a group divider. Each can be toggled in settings (default all on); settings show the glyph as the label. // build injects packages/core/editor-core.js (the shared editor core) at the next line: /* tile-family shared editor core — the SINGLE source of the editor engine used by BOTH plugins. @@ -118,6 +122,22 @@ function listContinuation(v, s) { return { text: v.slice(0, s) + '\n' + prefix + v.slice(s), caret: s + 1 + prefix.length }; // continue the list } +// Re-sequence contiguous top-level ordered-list blocks so they read 1,2,3… A markdown renderer renumbers +// regardless of the literal digits, but marktile SHOWS the markers — so deleting "2." should make the old +// "3." become "2.". Pure (unit-testable) and, for single-digit lists, caret-stable (a renumbered marker is +// the same width). A blank or non-ordered line ends a block; indented/nested ordered lists are left alone. +// Returns the SAME string when already sequential, so callers can no-op on identity (the common case). +function renumberLists(v) { + const lines = v.split('\n'); + let n = 0, changed = false; + for (let i = 0; i < lines.length; i++) { + const m = /^(\d+)([.)])(\s)/.exec(lines[i]); + if (m) { n++; const want = n + m[2] + m[3]; if (m[0] !== want) { lines[i] = want + lines[i].slice(m[0].length); changed = true; } } + else n = 0; // blank / non-ordered line breaks the block → next ordered run restarts at 1 + } + return changed ? lines.join('\n') : v; +} + // Tab inserts a literal tab at the caret (replacing any selection); Shift+Tab removes one tab immediately // before a collapsed caret. Tugtile's tile structure is tab-indented (serializeTile re-adds it on write), // so being able to type a real tab matters when editing a raw board file in marktile. Pure → unit-tested. @@ -183,7 +203,6 @@ function mountEditor(contentEl, opts, host) { 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.onCycle) { const vc = bar.createEl('button', { cls: 'tugtile-iconbtn tugtile-ed-cycle' }); const renderVc = () => { vc.empty(); const m = (opts.getMode && opts.getMode()) || EDITOR_MODES[0]; setIcon(vc.createSpan(), m.icon); vc.setAttribute('aria-label', t(m.name)); }; renderVc(); tap(vc, () => { opts.onCycle(); renderVc(); }); } // view-mode cycle in the bar (tugtile's modal): icon reflects the current mode, tap advances the rig + re-renders. marktile keeps its in the header instead. 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) @@ -312,7 +331,7 @@ function mountEditor(contentEl, opts, host) { const off = offsetAt(loc.node, loc.off); lastSel = { start: off, end: off }; return true; }; - const scheduleSync = () => { clearTimeout(syncT); syncT = setTimeout(() => { if (composing) return; if (!rehighlightLine()) rehighlight(); pushHist(); }, 140); }; + const scheduleSync = () => { clearTimeout(syncT); syncT = setTimeout(() => { if (composing) return; const v = getText(), r = renumberLists(v); if (r !== v) { const c = readSel() || lastSel; render(r); setSel(c.start, c.end); pushHist(); return; } if (!rehighlightLine()) rehighlight(); pushHist(); }, 140); }; // on idle: if an ordered list fell out of sequence (item deleted/moved) renumber it once; else the normal light rehighlight ed.addEventListener('compositionstart', () => { composing = true; }); ed.addEventListener('compositionend', () => { composing = false; scheduleSync(); }); ed.addEventListener('input', () => { if (!composing) scheduleSync(); }); @@ -383,7 +402,36 @@ function mountEditor(contentEl, opts, host) { // 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; - const togglePre = (pre) => { const v = getText(), s = sel().start, ls = lineStartOf(v, s); const ln = v.slice(ls); const has = ln.startsWith(pre); const nv = has ? v.slice(0, ls) + ln.slice(pre.length) : v.slice(0, ls) + pre + ln; const np = Math.max(ls, s + (has ? -pre.length : pre.length)); applyEdit(nv, np); }; + // Line-prefix tools (bullet / number / check / quote). With a SELECTION it applies to EVERY line the selection + // touches; with just a caret it toggles that one line (caret kept). ordered=true → numbers: any "N. " counts as + // the prefix (so toggling off works on existing numbers), and adding renumbers the block 1..N. Blank lines are + // left alone. Toggles OFF only when every non-blank line already has it. + const togglePre = (pre, ordered) => { + const v = getText(), s = sel().start, e = sel().end; + const re = ordered ? /^\d+\.\s/ : null; + const has = (ln) => (re ? re.test(ln) : ln.startsWith(pre)); + const strip = (ln) => (re ? ln.replace(re, '') : ln.slice(pre.length)); + if (s === e) { // no selection → just the caret's line, caret preserved (original behaviour) + const ls = lineStartOf(v, s), ln = v.slice(ls), h = has(ln); + const cut = h ? (ln.length - strip(ln).length) : 0; + const nv = h ? v.slice(0, ls) + strip(ln) + v.slice(ls + ln.length) : v.slice(0, ls) + (ordered ? '1. ' : pre) + v.slice(ls); + applyEdit(nv, Math.max(ls, s + (h ? -cut : (ordered ? 3 : pre.length)))); + return; + } + const firstLs = lineStartOf(v, s), lastLs = lineStartOf(v, e - 1); + const nlAfter = v.indexOf('\n', lastLs), blockEnd = nlAfter === -1 ? v.length : nlAfter; + const lines = v.slice(firstLs, blockEnd).split('\n'); + const nonBlank = lines.filter((ln) => ln.trim() !== ''); + const allHave = nonBlank.length > 0 && nonBlank.every(has); + let n = 0; + const out = lines.map((ln) => { + if (ln.trim() === '') return ln; // leave blank lines alone + if (allHave) return strip(ln); // every non-blank has it → remove + if (ordered) { n++; return n + '. ' + (re.test(ln) ? ln.replace(re, '') : ln); } // renumber the block 1..N + return has(ln) ? ln : pre + ln; // bullet/check/quote → add where missing + }).join('\n'); + applyEdit(v.slice(0, firstLs) + out + v.slice(blockEnd), firstLs, firstLs + out.length); // keep the block selected + }; const setHeading = (hashes) => { const v = getText(), s = sel().start, ls = lineStartOf(v, s); const rest = v.slice(ls); const m = /^#{1,6}\s/.exec(rest); const cur = m ? m[0].length : 0; const repl = (m && m[0] === hashes) ? '' : hashes; const nv = v.slice(0, ls) + repl + rest.slice(cur); const np = Math.max(ls, s + (repl.length - cur)); applyEdit(nv, np); }; // Bind a toolbar button so a TAP fires the action (keeping editor focus) but a SWIPE scrolls the row instead. // The old approach fired on touchstart+preventDefault, which was hair-trigger and blocked horizontal scrolling. @@ -406,7 +454,7 @@ function mountEditor(contentEl, opts, host) { undo: () => { if (hi > 0) { hi--; restore(hist[hi]); } }, redo: () => { if (hi < hist.length - 1) { hi++; restore(hist[hi]); } }, h1: () => setHeading('# '), h2: () => setHeading('## '), h3: () => setHeading('### '), bold: () => wrap('**', '**'), italic: () => wrap('*', '*'), strike: () => wrap('~~', '~~'), - bullet: () => togglePre('- '), number: () => togglePre('1. '), check: () => togglePre('- [ ] '), quote: () => togglePre('> '), + bullet: () => togglePre('- '), number: () => togglePre('1. ', true), check: () => togglePre('- [ ] '), quote: () => togglePre('> '), table: () => { const v = getText(), s = sel().start, ls = lineStartOf(v, s); const pre = (ls > 0 && v[ls - 1] !== '\n') ? '\n' : ''; const tbl = pre + '| | |\n| --- | --- |\n| | |\n'; applyEdit(v.slice(0, ls) + tbl + v.slice(ls), ls + pre.length + 2); }, // insert a starter 2×2 table; decorateTables grids it for in-place editing code: () => wrap('`', '`'), link: () => wrap('[[', ']]'), date: () => insertTok(host.dateTrigger || '@'), time: () => insertTok(host.timeTrigger || '@@'), @@ -498,6 +546,7 @@ function mountEditor(contentEl, opts, host) { rawValue: () => getText(), setText: (text) => { if (ed.getAttribute('contenteditable') === 'false') return; render(text); pushHist(); }, // programmatic whole-document replace (TOC drag-reorder); pushHist → undoable + fires onChange (autosave). read-only guard like applyEdit. isDirty: () => getText().replace(/\s+$/, '') !== orig.replace(/\s+$/, ''), + insertText: (text) => insertTok(text), // insert at the caret (used by image paste/drop); applyEdit's read-only guard applies focus: () => ta.focus(), destroy: () => { clearTimeout(syncT); if (vv) { vv.removeEventListener('resize', applyVV); vv.removeEventListener('scroll', applyVV); } if (sizer) { sizer.style.height = ''; sizer.style.maxHeight = ''; } }, }; @@ -517,10 +566,8 @@ class TileEditModal extends Modal { this.modalEl.addClass('tugtile-edit-modal-full'); this._ctrl = mountEditor(this.contentEl, { text: this._opts.text || '', - onCancel: () => this._requestClose(), onSave: () => this._doClose('save'), onSubmit: () => this._doClose('save'), onEscape: () => this._requestClose(), + onSubmit: () => this._doClose('save'), onEscape: () => this._requestClose(), // keyboard: Enter saves, Escape cancels (the ✕/✓ buttons live in the control strip below) onToc: () => { if (this._rig && this._rig.toc) this._rig.toc.toggle(); }, - onCycle: () => { if (this._rig) this._rig.cycleMode(); }, - getMode: () => (this._rig ? this._rig.currentMode() : EDITOR_MODES[0]), }, 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. @@ -528,6 +575,7 @@ class TileEditModal extends Modal { this._rig = equipEditor({ mount: this.contentEl, ctrl: this._ctrl, enabledModes: (this.host.plugin && this.host.plugin.settings && this.host.plugin.settings.modes) || {}, + saveImage: (blob) => saveVaultImage(app, srcPath, blob), // paste/drop an image → vault attachment + ![[…]] resolveSrc: (raw) => { raw = String(raw).split('|')[0].trim(); if (/^(https?:|data:|app:)/i.test(raw)) return raw; @@ -541,7 +589,23 @@ class TileEditModal extends Modal { sortableOptions: { delay: 180, delayOnTouchOnly: true, touchStartThreshold: 8, forceFallback: true, fallbackOnBody: true, fallbackTolerance: 4, dragClass: 'marktile-toc-item--drag' }, }, }); + // Control strip in marktile's exact markup, prepended above the toolbar: [✕] · viewcycle · lock · [✓]. So the + // big editor reads as marktile + the modal's cancel/save. (marktile builds the same-looking strip in its header.) + const strip = createDiv({ cls: 'tugtile__ctlbar' }); + this._ctl = buildEditorCtl(strip, { + cycleMode: () => { if (this._rig) this._rig.cycleMode(); }, + currentMode: () => (this._rig ? this._rig.currentMode() : EDITOR_MODES[0]), + toggleLock: () => this._toggleLock(), + isLocked: () => !!this._locked, + brand: t('mtBrand'), brandLocked: t('mtBrandLocked'), + modeLabel: t('mtModeToggle'), lockLabel: t('mtLockToggle'), + onCancel: () => this._requestClose(), cancelLabel: t('cancel'), + onSave: () => this._doClose('save'), saveLabel: t('save'), + }); + this.contentEl.prepend(strip); } + _toggleLock() { this._locked = !this._locked; this._applyLock(); } + _applyLock() { const ed = this.contentEl.querySelector('.tugtile-ed-rich'); if (ed) ed.setAttribute('contenteditable', String(!this._locked)); this.contentEl.toggleClass('tugtile--locked', !!this._locked); } _dirty() { return !!this._ctrl && this._ctrl.isDirty(); } close() { if (this._forceClose) { this._animateClose(); return; } @@ -1047,7 +1111,7 @@ const EDITOR_MODES = [ { key: 'plain', cls: 'tugtile-plain', icon: 'square-code', name: 'mtModePlain' }, ]; function equipEditor(opts) { - const { mount, ctrl, enabledModes, resolveSrc, toc } = opts; + const { mount, ctrl, enabledModes, resolveSrc, toc, saveImage } = opts; const onModes = () => { const md = enabledModes || {}; const on = EDITOR_MODES.filter((m) => md[m.key] !== false); return on.length ? on : EDITOR_MODES; }; let ix = 0; if (opts.initialMode) { const i = onModes().findIndex((m) => m.key === opts.initialMode); if (i >= 0) ix = i; } @@ -1061,16 +1125,77 @@ function equipEditor(opts) { const tableObs = decorateTables(mount, ctrl, 'marktile-grid'); const imgObs = resolveSrc ? decorateImages(mount, resolveSrc) : null; const tocCtl = toc ? wireToc(Object.assign({ mount, ctrl }, toc)) : null; + const paste = saveImage ? wireImagePaste(mount, ctrl, saveImage) : null; applyMode(); return { currentMode: current, applyMode, cycleMode() { ix = (ix + 1) % onModes().length; applyMode(); }, toc: tocCtl, - destroy() { try { tableObs.disconnect(); } catch (e) {} if (imgObs) { try { imgObs.disconnect(); } catch (e) {} } if (tocCtl) tocCtl.destroy(); }, + 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 into the contenteditable that getText() can't +// see, so a pasted picture silently vanishes on the next render. Here we intercept image paste/drop, hand the blob +// to the host's saveImage(blob) → markdown link (Obsidian: save to the vault attachment folder; ejecta: upload), +// and insert that link at the caret — then decorateImages shows the thumbnail. Returns { destroy }. +function wireImagePaste(root, ctrl, saveImage) { + const handle = async (files) => { + for (const f of files) { + if (!f || !f.type || f.type.indexOf('image/') !== 0) continue; + try { const link = await saveImage(f); if (link) ctrl.insertText(link); } catch (e) {} + } + }; + const onPaste = (e) => { + const items = e.clipboardData && e.clipboardData.items; const files = []; + if (items) for (const it of items) { if (it.kind === 'file') { const f = it.getAsFile(); if (f && f.type && f.type.indexOf('image/') === 0) files.push(f); } } + if (files.length) { e.preventDefault(); e.stopPropagation(); handle(files); } + }; + const onDrop = (e) => { + const fl = e.dataTransfer && e.dataTransfer.files; const imgs = fl ? [...fl].filter((f) => f.type && f.type.indexOf('image/') === 0) : []; + if (imgs.length) { e.preventDefault(); e.stopPropagation(); handle(imgs); } + }; + root.addEventListener('paste', onPaste, true); + root.addEventListener('drop', onDrop, true); + return { destroy() { root.removeEventListener('paste', onPaste, true); root.removeEventListener('drop', onDrop, true); } }; +} + +// Obsidian saveImage hook — save a pasted/dropped blob into the vault's attachment folder and return an embed link. +// Used by both Obsidian hosts (marktile + modaltile); ejecta passes its own web-upload saveImage instead. +async function saveVaultImage(app, sourcePath, blob) { + const ext = (blob.type && blob.type.split('/')[1]) || 'png'; + const stamp = (typeof Date !== 'undefined' && Date.now) ? Date.now() : Math.floor(performance.now()); + const path = await app.fileManager.getAvailablePathForAttachment('pasted-' + stamp + '.' + ext, sourcePath || ''); + const file = await app.vault.createBinary(path, await blob.arrayBuffer()); + return '![[' + file.name + ']]'; +} + const DEFAULT_SETTINGS = { laneWidth: 'medium', // Lane width preset: 'narrow' | 'medium' | 'wide' (mapped to px by LANE_WIDTHS) tableDensity: 'medium', // Table row spacing preset: 'narrow' | 'medium' | 'wide' (mapped to px by TABLE_PADS) @@ -1082,6 +1207,7 @@ const DEFAULT_SETTINGS = { archiveHeading: 'Archive', // Heading for newly created archive sections (uses localized strings in kanban to align when returning to kanban; can be customized to "Archive", etc.) responsiveBoard: false, // When on, the board auto-reflows to a vertical stack on narrow panes editorTools: {}, // Per-button on/off for the editor toolbar (missing key = on); see EDITOR_TOOLS + modes: {}, // Per-mode on/off for the big editor's view cycle (missing key = on); see EDITOR_MODES }; const VIEW_TYPE = 'tugtile-board'; @@ -1790,10 +1916,7 @@ class BoardView extends ItemView { handle: '.tugtile__lane-head', filter: '.tugtile__addcol', animation: 150, - delay: 180, - delayOnTouchOnly: true, - forceFallback: true, - fallbackOnBody: true, + ...DRAG_TOUCH, // lane drag now matches the tile: a still tap/long-press to rename isn't hijacked into a drag ghostClass: 'tugtile__lane--ghost', chosenClass: 'tugtile__lane--chosen', dragClass: 'tugtile__lane--drag', @@ -1903,12 +2026,7 @@ class BoardView extends ItemView { group: 'tugtile', disabled: !!this._locked, // No drag-and-drop while the board is locked animation: 150, - delay: 180, - delayOnTouchOnly: true, - touchStartThreshold: 8, // Tolerate a little finger tremor before a drag engages, so a still long-press can reach the context menu instead of accidentally dragging - forceFallback: true, - fallbackOnBody: true, - fallbackTolerance: 4, + ...DRAG_TOUCH, // shared touch tuning (see DRAG_TOUCH) — long-press + tremor tolerance, identical to the lanes scroll: false, ghostClass: 'tugtile__tile--ghost', chosenClass: 'tugtile__tile--chosen', @@ -2276,7 +2394,7 @@ class BoardView extends ItemView { this._openMenu = menu; const p0 = (e && e.touches && e.touches[0]) || e || {}; const ox = p0.clientX || 0, oy = p0.clientY || 0; - const onMove = (ev) => { const p = (ev.touches && ev.touches[0]) || ev; if (Math.abs((p.clientX || 0) - ox) > 12 || Math.abs((p.clientY || 0) - oy) > 12) this.closeOpenMenu(); }; // Starting to drag/scroll dismisses the menu + const onMove = (ev) => { if (ev.pointerType === 'mouse') return; const p = (ev.touches && ev.touches[0]) || ev; if (Math.abs((p.clientX || 0) - ox) > 12 || Math.abs((p.clientY || 0) - oy) > 12) this.closeOpenMenu(); }; // touch/pen drag-to-scroll dismisses the menu; mouse is EXEMPT (otherwise moving toward the popup, always >12px, closed it before you could reach it) this._menuMove = onMove; document.addEventListener('pointermove', onMove, { capture: true, passive: true }); document.addEventListener('touchmove', onMove, { capture: true, passive: true }); @@ -3309,6 +3427,24 @@ class TugtileSettingTab extends PluginSettingTab { toolPicker(t('gFormatTools'), t('gFormatToolsDesc'), 'format'); toolPicker(t('gBlockTools'), t('gBlockToolsDesc'), 'block'); toolPicker(t('gInsertTools'), t('gInsertToolsDesc'), 'insert'); + // Which view modes the big editor's (modaltile) cycle rotates through — same picker marktile has. The modal + // reads this at open, so a change takes effect on the next card open (no reload needed). + 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 + md[m.key] = cb.checked; await this.plugin.saveSettings(); + }; + }); + }); new Setting(containerEl) .setName(t('gEnterSubmit')) diff --git a/manifest.json b/manifest.json index 47d195d..a761fd6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "tugtile", "name": "tugtile", - "version": "0.0.205", + "version": "0.0.214", "minAppVersion": "1.4.0", "description": "A card table for your Markdown notes — tug tiles to reorder. Reads your existing kanban boards. CJK-friendly.", "author": "CVER Inc.", diff --git a/styles.css b/styles.css index a832b54..74ec4dc 100644 --- a/styles.css +++ b/styles.css @@ -1,4 +1,19 @@ /* tugtile — markdown kanban (prototype, read-only renderer) */ + +/* Squircle (continuous-curvature) corners — progressive enhancement. + corner-shape is Chromium-only as of mid-2026; Safari/WebKit & Firefox ignore it and keep the + border-radius arc, so this is ZERO visual regression. Requires a non-zero border-radius (still + set per-surface below). Token lives here because corner-shape is NOT inherited but a custom + property IS. Applied only to finite-radius card/button/panel/input surfaces — never pills/circles. */ +:root { --corner: squircle; } +:where( + .tugtile-brand, .tugtile__lane, .tugtile__tile, .tugtile__num, .tugtile__popup, + .tugtile__popup-d, .tugtile__popup-t, .tugtile__add-btn, .tugtile__add-input, .tugtile__add-ok, + .tugtile-prompt-field, .tugtile-iconbtn, .tugtile-ed-find-i, .tugtile__tile-edit, + .tugtile__lane-rename, .tugtile-viewcycle, .tugtile__archivebar, .tugtile__lane-chevron, + .tugtile-archive-row, .tugtile__addcol-btn +) { corner-shape: var(--corner); } + .tugtile { height: 100%; padding: 0; @@ -47,6 +62,13 @@ body.is-phone .workspace-leaf-content[data-type="tugtile-board"] .view-content { cursor: pointer; border-radius: var(--radius-s); padding: 2px 6px; } .tugtile-brand:hover { background: var(--background-modifier-hover); color: var(--text-muted); } + +/* TileEditModal (tugtile's big editor) control strip — scoped to the modal so marktile's own strip is untouched. + A uniform gray bar spanning the full width: ✕ pinned far left, ✓ far right, the mode·lock centered between. */ +.tugtile-edit-modal-full .tugtile__ctlbar { background: var(--background-secondary); } +.tugtile-edit-modal-full .tugtile-headerctl { display: flex; width: 100%; } +.tugtile-edit-modal-full .tugtile-headerctl > .tugtile-iconbtn:first-child { margin-right: auto; } /* ✕ → far left */ +.tugtile-edit-modal-full .tugtile-headerctl > .tugtile-iconbtn:last-child { margin-left: auto; } /* ✓ → far right */ .tugtile-lock-icon { display: inline-flex; } .tugtile-lock-icon svg { width: 0.9em; height: 0.9em; } /* Locked board (read-only): hide add/edit affordances, disable checkboxes, drop drag cursors. View-only actions (collapse, view switch, search) still work. */ diff --git a/versions.json b/versions.json index 1b19915..dee3cbe 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "0.0.205": "1.4.0" + "0.0.214": "1.4.0" }