mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-10-19 02:09:26 +00:00
feat(export): note export (MD/HTML/DOCX/PDF) + FormatBlock fix + table borders
Some checks are pending
Build and push Joplock image / build-and-push (push) Waiting to run
Some checks are pending
Build and push Joplock image / build-and-push (push) Waiting to run
Export: - Export button in TinyMCE toolbar (rendered mode) and mobile hamburger menu - Format dropdown: Markdown, HTML, Word (.docx), PDF (print window) - html-docx.js vendored UMD bundle for DOCX export - PDF opens clean print window: white bg, black text, @page margins, no header/footer - Esc closes export menu; Cancel button in dropdown - Export menu hidden in markdown mode; positioned via fixed+JS to avoid overflow clipping FormatBlock fix: - blocks dropdown was converting the whole note when content used linebreak mode (single <p> with <br> separators). NodeChange handler now splits <br>-joined blocks into individual <p> elements on caret entry so FormatBlock targets only the selected line. - 6 Playwright tests in playwright-tests/format-block.spec.js covering caret, triple-click, keyboard selection, existing-heading, revert-to-paragraph, and the linebreak-mode regression case. Table borders: - 3px solid borders in markdown preview (styles.css), TinyMCE content_style (with !important to override skin defaults), and PDF print window. - Table header bg + bold in all three contexts.
This commit is contained in:
parent
05ff14771e
commit
d0f58b5037
7 changed files with 13725 additions and 7 deletions
|
|
@ -355,6 +355,8 @@ const editorFragment = (note, folders, currentFolderId = '') => {
|
|||
<input type="file" id="file-upload" style="display:none" accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt" multiple onchange="handleFilePicker(this)" />
|
||||
<span class="tb-div"></span>
|
||||
<button type="button" class="tb" title="Note history" onclick="openHistoryModal('${escapeHtml(note.id)}')">🕐</button>
|
||||
<span class="tb-div"></span>
|
||||
<button type="button" class="tb" id="export-note-btn" title="Export note" onclick="toggleExportMenu()">💾</button>
|
||||
</div>
|
||||
<div class="search-nav-bar" id="search-nav-bar" hidden>
|
||||
<span class="search-nav-counter" id="search-nav-counter"></span>
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ const layoutPage = (options = {}) => {
|
|||
<script src="/codemirror.min.js?v=${ASSET_VERSION}"></script>
|
||||
<script src="/tinymce/tinymce.min.js"></script>
|
||||
<script src="/hljs.min.js"></script>
|
||||
<script src="/html-docx.js"></script>
|
||||
<script>window.joplockStripNoteTitle=${stripMarkdownForTitle.toString()};</script>
|
||||
<script src="/app.js?v=${ASSET_VERSION}" defer></script>
|
||||
<title>Joplock</title>
|
||||
|
|
@ -191,6 +192,7 @@ const layoutPage = (options = {}) => {
|
|||
<div class="mobile-ctx-sheet" id="mobile-ctx-sheet" style="display:none">
|
||||
<div class="mobile-ctx-title" id="mobile-ctx-title"></div>
|
||||
<div class="mobile-ctx-meta" id="mobile-ctx-meta" style="display:none"></div>
|
||||
<button class="mobile-ctx-btn" id="mobile-ctx-export" onclick="mobileCtxExport()" style="display:none">💾 Export note</button>
|
||||
<button class="mobile-ctx-btn" id="mobile-ctx-move">📁 Move note</button>
|
||||
<button class="mobile-ctx-btn" id="mobile-ctx-delete">🗑 Delete note</button>
|
||||
<button class="mobile-ctx-btn mobile-ctx-btn-cancel" onclick="mobileCtxClose()">Cancel</button>
|
||||
|
|
@ -257,6 +259,13 @@ const layoutPage = (options = {}) => {
|
|||
</select>
|
||||
<a href="/logout" class="btn btn-sm btn-secondary logout-link" onclick="return confirmLogout(event)">Logout</a>
|
||||
</div>
|
||||
<div class="export-menu" id="export-menu" hidden>
|
||||
<button type="button" onclick="exportNoteAsMarkdown();closeExportMenu()">Markdown (.md)</button>
|
||||
<button type="button" onclick="exportNoteAsHtml();closeExportMenu()">HTML (.html)</button>
|
||||
<button type="button" onclick="exportNoteAsDocx();closeExportMenu()">Word (.docx)</button>
|
||||
<button type="button" onclick="exportNoteAsPdf();closeExportMenu()">PDF (print)</button>
|
||||
<button type="button" class="export-menu-cancel" onclick="closeExportMenu()">Cancel</button>
|
||||
</div>
|
||||
<div class="code-modal-panel" id="code-modal" hidden>
|
||||
<form class="code-modal-inner" id="code-edit-form" onsubmit="submitCode(event)">
|
||||
<div class="code-modal-header">
|
||||
|
|
|
|||
232
playwright-tests/format-block.spec.js
Normal file
232
playwright-tests/format-block.spec.js
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
'use strict';
|
||||
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
acceptDialogs,
|
||||
createDesktopNote,
|
||||
createNotebook,
|
||||
login,
|
||||
setNoteBody,
|
||||
teardownTestData,
|
||||
waitForSaved,
|
||||
slug,
|
||||
} = require('./helpers');
|
||||
|
||||
const BODY = [
|
||||
'First paragraph stays as paragraph.',
|
||||
'',
|
||||
'Second paragraph becomes a heading.',
|
||||
'',
|
||||
'Third paragraph stays as paragraph.',
|
||||
].join('\n');
|
||||
|
||||
// Note with heading already present and multiple blank lines
|
||||
const BODY_WITH_HEADING = [
|
||||
'# Existing heading',
|
||||
'',
|
||||
'Normal paragraph one.',
|
||||
'',
|
||||
'Normal paragraph two.',
|
||||
'',
|
||||
'Normal paragraph three.',
|
||||
].join('\n');
|
||||
|
||||
async function setupNote(page, body) {
|
||||
body = body || BODY;
|
||||
const mdToggle = page.locator('#editor-panel #markdown-toggle');
|
||||
if (!(await mdToggle.evaluate(el => el.classList.contains('active')))) {
|
||||
await mdToggle.click();
|
||||
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
await setNoteBody(page, body);
|
||||
await waitForSaved(page);
|
||||
await page.locator('#editor-panel #preview-toggle').click();
|
||||
await expect(page.locator('iframe.tox-edit-area__iframe')).toBeVisible({ timeout: 15000 });
|
||||
const iframeBody = page.frameLocator('iframe.tox-edit-area__iframe').locator('body');
|
||||
// Wait for something from the body
|
||||
await expect(iframeBody).not.toBeEmpty({ timeout: 15000 });
|
||||
return iframeBody;
|
||||
}
|
||||
|
||||
// Click the TinyMCE blocks dropdown and choose a block type by name
|
||||
async function applyBlockFormat(page, formatName) {
|
||||
const blocksBtn = page.locator('button[data-mce-name="blocks"]');
|
||||
await expect(blocksBtn).toBeVisible({ timeout: 5000 });
|
||||
await blocksBtn.click();
|
||||
const option = page.locator('.tox-collection__item').filter({ hasText: new RegExp(`^${formatName}$`) }).first();
|
||||
await expect(option).toBeVisible({ timeout: 5000 });
|
||||
await option.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Get block structure from the TinyMCE iframe
|
||||
async function getBlocks(iframeBody) {
|
||||
return iframeBody.evaluate(body =>
|
||||
Array.from(body.querySelectorAll('p,h1,h2,h3,h4,h5,h6'))
|
||||
.map(el => ({ tag: el.tagName.toLowerCase(), text: el.textContent.trim() }))
|
||||
.filter(b => b.text)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('rendered mode FormatBlock (blocks dropdown)', () => {
|
||||
let folder;
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop', 'Desktop only — TinyMCE blocks toolbar not present on mobile');
|
||||
acceptDialogs(page);
|
||||
await login(page);
|
||||
folder = slug('fmt');
|
||||
await createNotebook(page, folder);
|
||||
await createDesktopNote(page, folder);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await teardownTestData(page, { folders: [folder] });
|
||||
});
|
||||
|
||||
test('blocks dropdown formats only the paragraph where caret is placed', async ({ page }) => {
|
||||
const iframeBody = await setupNote(page);
|
||||
|
||||
// Verify initial: no headings
|
||||
const initial = await getBlocks(iframeBody);
|
||||
expect(initial.filter(b => b.tag !== 'p').length, 'no headings before formatting').toBe(0);
|
||||
|
||||
const secondPara = iframeBody.locator('p', { hasText: 'Second paragraph becomes a heading.' }).first();
|
||||
await expect(secondPara).toBeVisible();
|
||||
await secondPara.click();
|
||||
|
||||
await applyBlockFormat(page, 'Heading 3');
|
||||
|
||||
const after = await getBlocks(iframeBody);
|
||||
const h3s = after.filter(b => b.tag === 'h3');
|
||||
expect(h3s.length, 'exactly one h3').toBe(1);
|
||||
expect(h3s[0].text).toContain('Second paragraph');
|
||||
|
||||
expect(after.find(b => b.text.includes('First paragraph'))?.tag, 'first unchanged').toBe('p');
|
||||
expect(after.find(b => b.text.includes('Third paragraph'))?.tag, 'third unchanged').toBe('p');
|
||||
});
|
||||
|
||||
test('blocks dropdown with triple-click selection formats only that paragraph', async ({ page }) => {
|
||||
const iframeBody = await setupNote(page);
|
||||
|
||||
const secondPara = iframeBody.locator('p', { hasText: 'Second paragraph becomes a heading.' }).first();
|
||||
await secondPara.click({ clickCount: 3 });
|
||||
|
||||
await applyBlockFormat(page, 'Heading 2');
|
||||
|
||||
const after = await getBlocks(iframeBody);
|
||||
const h2s = after.filter(b => b.tag === 'h2');
|
||||
expect(h2s.length, 'exactly one h2').toBe(1);
|
||||
expect(h2s[0].text).toContain('Second paragraph');
|
||||
expect(after.find(b => b.text.includes('First paragraph'))?.tag, 'first unchanged').toBe('p');
|
||||
expect(after.find(b => b.text.includes('Third paragraph'))?.tag, 'third unchanged').toBe('p');
|
||||
});
|
||||
|
||||
test('blocks dropdown keyboard selection formats only selected block', async ({ page }) => {
|
||||
const iframeBody = await setupNote(page);
|
||||
|
||||
const secondPara = iframeBody.locator('p', { hasText: 'Second paragraph becomes a heading.' }).first();
|
||||
await secondPara.click();
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.press('Shift+End');
|
||||
|
||||
await applyBlockFormat(page, 'Heading 1');
|
||||
|
||||
const after = await getBlocks(iframeBody);
|
||||
const h1s = after.filter(b => b.tag === 'h1');
|
||||
expect(h1s.length, 'exactly one h1').toBe(1);
|
||||
expect(h1s[0].text).toContain('Second paragraph');
|
||||
expect(after.find(b => b.text.includes('First paragraph'))?.tag, 'first unchanged').toBe('p');
|
||||
expect(after.find(b => b.text.includes('Third paragraph'))?.tag, 'third unchanged').toBe('p');
|
||||
});
|
||||
|
||||
test('blocks dropdown does not reformat existing heading to wrong type', async ({ page }) => {
|
||||
const iframeBody = await setupNote(page, BODY_WITH_HEADING);
|
||||
|
||||
await expect(iframeBody.locator('h1', { hasText: 'Existing heading' })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click in paragraph one and apply H2
|
||||
const para1 = iframeBody.locator('p', { hasText: 'Normal paragraph one.' }).first();
|
||||
await para1.click();
|
||||
|
||||
await applyBlockFormat(page, 'Heading 2');
|
||||
|
||||
const after = await getBlocks(iframeBody);
|
||||
// Original h1 still h1
|
||||
expect(after.find(b => b.text.includes('Existing heading'))?.tag, 'existing h1 unchanged').toBe('h1');
|
||||
// Para 1 now h2
|
||||
expect(after.find(b => b.text.includes('Normal paragraph one'))?.tag, 'para1 now h2').toBe('h2');
|
||||
// Paragraphs 2 and 3 still p
|
||||
expect(after.find(b => b.text.includes('Normal paragraph two'))?.tag, 'para2 unchanged').toBe('p');
|
||||
expect(after.find(b => b.text.includes('Normal paragraph three'))?.tag, 'para3 unchanged').toBe('p');
|
||||
});
|
||||
|
||||
test('applying Paragraph type reverts a heading back to paragraph', async ({ page }) => {
|
||||
const iframeBody = await setupNote(page, BODY_WITH_HEADING);
|
||||
|
||||
await expect(iframeBody.locator('h1', { hasText: 'Existing heading' })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click in the h1 and apply Paragraph
|
||||
const heading = iframeBody.locator('h1', { hasText: 'Existing heading' }).first();
|
||||
await heading.click();
|
||||
|
||||
await applyBlockFormat(page, 'Paragraph');
|
||||
|
||||
const after = await getBlocks(iframeBody);
|
||||
// Former h1 is now p
|
||||
expect(after.find(b => b.text.includes('Existing heading'))?.tag, 'heading reverted to p').toBe('p');
|
||||
// Other paragraphs unchanged
|
||||
expect(after.filter(b => b.tag === 'h1').length, 'no h1 remains').toBe(0);
|
||||
});
|
||||
|
||||
test('blocks dropdown on linebreak-mode note formats only the selected line, not whole note', async ({ page }) => {
|
||||
// This is the bug scenario: notes written without blank lines (linebreak mode)
|
||||
// render as a single <p> with <br> separators. FormatBlock must not convert
|
||||
// the whole <p> when only one line is selected.
|
||||
const linebreakBody = [
|
||||
'First line of note.',
|
||||
'Second line becomes heading.',
|
||||
'Third line stays as is.',
|
||||
].join('\n'); // no blank lines — renders as single <p> with <br>
|
||||
|
||||
const iframeBody = await setupNote(page, linebreakBody);
|
||||
|
||||
// Verify it renders as a single block (all text in one element)
|
||||
const initialBlocks = await getBlocks(iframeBody);
|
||||
// All text should be in one block element
|
||||
const allText = initialBlocks.map(b => b.text).join(' ');
|
||||
expect(allText).toContain('First line');
|
||||
expect(allText).toContain('Second line');
|
||||
expect(allText).toContain('Third line');
|
||||
|
||||
// Click on the second line text — it's inside the <br>-joined block
|
||||
// We use evaluate to click on the text node of the second line
|
||||
await iframeBody.evaluate(body => {
|
||||
// Find a text node containing 'Second line' and click it
|
||||
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.textContent.includes('Second line')) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, 0);
|
||||
range.collapse(true);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await applyBlockFormat(page, 'Heading 3');
|
||||
|
||||
const after = await getBlocks(iframeBody);
|
||||
const h3s = after.filter(b => b.tag === 'h3');
|
||||
|
||||
// Only the second line should be h3 — NOT the entire note
|
||||
expect(h3s.length, 'exactly one h3').toBe(1);
|
||||
expect(h3s[0].text).toContain('Second line');
|
||||
expect(h3s[0].text).not.toContain('First line');
|
||||
expect(h3s[0].text).not.toContain('Third line');
|
||||
});
|
||||
});
|
||||
235
public/app.js
235
public/app.js
|
|
@ -424,6 +424,118 @@ function tinyMCEContent(){return _tinymceEditor?_tinymceEditor.getContent():''}
|
|||
function tinyMCESetContent(html){if(_tinymceEditor)_tinymceEditor.setContent(html)}
|
||||
function tinyMCESyncToTA(){var ta=getTA();if(ta&&_tinymceEditor){var html=_tinymceEditor.getContent();var md=tinymceToMarkdown(html);if(ta.value!==md){ta.value=md;ta.dispatchEvent(new Event('input',{bubbles:true}));return true}}return false}
|
||||
function _isMarkdownModeActive(){return _editorMode==='markdown'||_editorMode==='md'}
|
||||
|
||||
/* ---------------- Note export (rendered mode only): MD / HTML / DOCX / PDF ---------------- */
|
||||
function _downloadBlob(blob,filename){
|
||||
var url=URL.createObjectURL(blob);
|
||||
var a=document.createElement('a');
|
||||
a.href=url;
|
||||
a.download=filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function(){URL.revokeObjectURL(url)},1000);
|
||||
}
|
||||
function _exportFilenameBase(){
|
||||
var hi=queryActiveEditor('.editor-title-hidden');
|
||||
var raw=(hi&&hi.value?hi.value:'').trim();
|
||||
var safe=raw.replace(/[\/\\:*?"<>|]/g,'').replace(/\s+/g,' ').trim();
|
||||
return safe||'note';
|
||||
}
|
||||
function _buildExportHtmlDoc(bodyHtml){
|
||||
return '<!DOCTYPE html>\n<html><head><meta charset="utf-8"></head><body>'+(bodyHtml||'')+'</body></html>';
|
||||
}
|
||||
function toggleExportMenu(anchorEl){
|
||||
var menu=document.getElementById('export-menu');
|
||||
// Find the best anchor: explicit arg, custom toolbar btn, TinyMCE toolbar btn, mobile header btn
|
||||
var btn=anchorEl
|
||||
||queryActiveEditor('#export-note-btn')
|
||||
||(document.querySelector('.tox-tbtn[aria-label="Export note"]'))
|
||||
||document.getElementById('mobile-editor-menu-btn');
|
||||
// If custom toolbar btn exists but isn't rendered (rich mode hides it), skip to TinyMCE btn
|
||||
if(btn&&btn.id==='export-note-btn'&&!btn.offsetParent){
|
||||
btn=document.querySelector('.tox-tbtn[aria-label="Export note"]')
|
||||
||document.getElementById('mobile-editor-menu-btn');
|
||||
}
|
||||
if(!menu||!btn)return;
|
||||
if(!menu.hidden){menu.hidden=true;return}
|
||||
menu.hidden=false;
|
||||
var r=btn.getBoundingClientRect();
|
||||
var mw=menu.offsetWidth||180;
|
||||
var mh=menu.offsetHeight||160;
|
||||
var left=r.right-mw;
|
||||
if(left<4)left=4;
|
||||
var top=r.bottom+4;
|
||||
if(top+mh>window.innerHeight-4)top=Math.max(4,r.top-mh-4);
|
||||
menu.style.left=left+'px';
|
||||
menu.style.top=top+'px';
|
||||
}
|
||||
function closeExportMenu(){
|
||||
var menu=document.getElementById('export-menu');
|
||||
if(menu)menu.hidden=true;
|
||||
}
|
||||
document.addEventListener('click',function(e){
|
||||
var menu=document.getElementById('export-menu');
|
||||
if(!menu||menu.hidden)return;
|
||||
if(menu.contains(e.target))return;
|
||||
if(e.target.closest&&e.target.closest('#export-note-btn'))return;
|
||||
closeExportMenu();
|
||||
});
|
||||
window.addEventListener('scroll',closeExportMenu,true);
|
||||
window.addEventListener('resize',closeExportMenu);
|
||||
function exportNoteAsMarkdown(){
|
||||
var ta=getTA();
|
||||
var md=ta?ta.value:'';
|
||||
_downloadBlob(new Blob([md],{type:'text/markdown'}),_exportFilenameBase()+'.md');
|
||||
}
|
||||
function exportNoteAsHtml(){
|
||||
var html=_buildExportHtmlDoc(tinyMCEContent());
|
||||
_downloadBlob(new Blob([html],{type:'text/html'}),_exportFilenameBase()+'.html');
|
||||
}
|
||||
function exportNoteAsDocx(){
|
||||
if(!window.htmlDocx||!window.htmlDocx.asBlob){alert('DOCX export is unavailable.');return}
|
||||
var html=_buildExportHtmlDoc(tinyMCEContent());
|
||||
try{
|
||||
var blob=window.htmlDocx.asBlob(html);
|
||||
_downloadBlob(blob,_exportFilenameBase()+'.docx');
|
||||
}catch(err){
|
||||
console.error('DOCX export failed:',err);
|
||||
alert('DOCX export failed.');
|
||||
}
|
||||
}
|
||||
function exportNoteAsPdf(){
|
||||
var html=tinyMCEContent();
|
||||
var printDoc='<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n<style>\n'+
|
||||
'@page { margin: 1.5cm; size: A4; }\n'+
|
||||
'@page { @top-left { content: none; } @top-center { content: none; } @top-right { content: none; } @bottom-left { content: none; } @bottom-center { content: none; } @bottom-right { content: none; } }\n'+
|
||||
'* { box-sizing: border-box; margin: 0; padding: 0; }\n'+
|
||||
'html, body { background: #fff; color: #000; font-family: sans-serif; font-size: 14px; line-height: 1.6; }\n'+
|
||||
'h1,h2,h3,h4,h5,h6 { color: #000; margin: 1em 0 0.4em; }\n'+
|
||||
'p { margin: 0.6em 0; }\n'+
|
||||
'a { color: #0066cc; }\n'+
|
||||
'code { background: #f4f4f4; color: #222; padding: 1px 4px; border-radius: 3px; font-size: 0.9em; }\n'+
|
||||
'pre { background: #f4f4f4; color: #222; padding: 12px; border-radius: 4px; overflow-wrap: break-word; white-space: pre-wrap; margin: 0.8em 0; }\n'+
|
||||
'pre code { background: none; padding: 0; }\n'+
|
||||
'blockquote { border-left: 3px solid #ccc; padding-left: 12px; color: #555; margin: 0.6em 0; }\n'+
|
||||
'table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }\n'+
|
||||
'th, td { border: 3px solid #ccc; padding: 6px 10px; text-align: left; }\n'+
|
||||
'th { background: #f0f0f0; }\n'+
|
||||
'img { max-width: 100%; height: auto; }\n'+
|
||||
'ul, ol { padding-left: 1.5em; margin: 0.6em 0; }\n'+
|
||||
'hr { border: none; border-top: 1px solid #ccc; margin: 1em 0; }\n'+
|
||||
'@media print {\n'+
|
||||
' @page { margin: 1.5cm; }\n'+
|
||||
' html, body { background: #fff !important; color: #000 !important; }\n'+
|
||||
'}\n'+
|
||||
'</style>\n</head>\n<body>'+html+'</body>\n</html>';
|
||||
var w=window.open('','_blank','width=800,height=600');
|
||||
if(!w){alert('Could not open print window. Check your popup blocker.');return}
|
||||
w.document.write(printDoc);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
w.onload=function(){w.print();};
|
||||
setTimeout(function(){if(w&&!w.closed)w.print();},400);
|
||||
}
|
||||
function _runMarkdownToolbarFormat(cmd){
|
||||
if(cmd==='bold'){wrapSel('**','**');return true}
|
||||
if(cmd==='italic'){wrapSel('*','*');return true}
|
||||
|
|
@ -654,6 +766,9 @@ function _tinyMCEContentFontStyle(){
|
|||
+'blockquote{border-left:3px solid var(--accent);color:var(--text-dim)}'
|
||||
+'hr{border-color:var(--border)}'
|
||||
+'img{max-width:100%;height:auto;border-radius:6px}'
|
||||
+'table{border-collapse:collapse!important;width:100%;margin:0.8em 0}'
|
||||
+'th,td{border:3px solid var(--border)!important;padding:6px 10px;text-align:left}'
|
||||
+'th{background:var(--bg-hover);font-weight:bold}'
|
||||
+'.md-checkbox{display:flex;align-items:baseline;gap:0.35em;margin:0.25em 0;padding-left:24px}'
|
||||
+'.md-checkbox::before{content:"";display:inline-block;width:16px;height:16px;border:1.5px solid var(--accent);border-radius:3px;flex-shrink:0;background:transparent;box-sizing:border-box}'
|
||||
+'.md-checkbox.checked::before{background:var(--accent);border-color:var(--accent);content:"\\2713";color:var(--bg);font-size:11px;font-weight:bold;line-height:16px;text-align:center}'
|
||||
|
|
@ -674,7 +789,7 @@ function _syncTinyMCEThemeVars(){
|
|||
}catch(_e){}
|
||||
}
|
||||
function _tinyMCEToolbarSpec(){
|
||||
return 'jop_edit | bold italic underline strikethrough | blocks | bullist numlist jop_checkbox | code jop_code blockquote hr | jop_date jop_datetime | removeformat | link image jop_upload table | jop_spellcheck jop_history';
|
||||
return 'jop_edit | bold italic underline strikethrough | blocks | bullist numlist jop_checkbox | code jop_code blockquote hr | jop_date jop_datetime | removeformat | link image jop_upload table | jop_spellcheck jop_history jop_export';
|
||||
}
|
||||
// Spellcheck state (client-only, persisted in localStorage). Default off so
|
||||
// shared browsers don't leak note text to remote spellcheck services.
|
||||
|
|
@ -831,6 +946,7 @@ function initPersistentTinyMCE(){
|
|||
editor.ui.registry.addIcon('jop_datetime','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="3.5" y="5.5" width="12" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M6.5 4.5v3M12.5 4.5v3M3.5 9.5h12" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round"/><circle cx="17.5" cy="16.5" r="3.8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M17.5 14.8v2.1l1.5.9" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||||
editor.ui.registry.addIcon('jop_upload','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.2 12.8l5.3-5.3a3 3 0 114.3 4.3l-6.4 6.4a4.5 4.5 0 01-6.4-6.4l6.4-6.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||||
editor.ui.registry.addIcon('jop_history','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 12a8 8 0 108-8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M4 5v4h4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 8.5V12l2.5 1.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||||
editor.ui.registry.addIcon('jop_export','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 3v10m0 0l-3-3m3 3l3-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>');
|
||||
editor.ui.registry.addButton('jop_checkbox',{
|
||||
tooltip:'Checkbox',
|
||||
icon:'jop_checkbox',
|
||||
|
|
@ -860,6 +976,11 @@ function initPersistentTinyMCE(){
|
|||
if(noteId)openHistoryModal(noteId);
|
||||
}
|
||||
});
|
||||
editor.ui.registry.addButton('jop_export',{
|
||||
tooltip:'Export note',
|
||||
icon:'jop_export',
|
||||
onAction:function(){toggleExportMenu();}
|
||||
});
|
||||
// Custom code button opens our themed full-screen CM6 code modal
|
||||
// instead of TinyMCE's built-in (unthemed, non-highlighting)
|
||||
// codesample dialog. Uses a distinct name so it doesn't depend on
|
||||
|
|
@ -1148,6 +1269,94 @@ function initPersistentTinyMCE(){
|
|||
}
|
||||
editor.on('input',onEdit);
|
||||
editor.on('change',onEdit);
|
||||
// FormatBlock fix: when newline_behavior='linebreak', soft-wrapped lines live
|
||||
// inside a single <p> separated by <br>. TinyMCE's FormatBlock command
|
||||
// (used by the blocks dropdown) operates at block granularity — it converts
|
||||
// the whole <p> to a heading even if only part of it is selected.
|
||||
// Fix: whenever the caret enters a <p> that contains <br> children, split
|
||||
// that <p> at the <br> boundaries into individual <p> elements. This gives
|
||||
// FormatBlock proper paragraph boundaries to work with, and also makes
|
||||
// selection behaviour more predictable for multi-line linebreak-mode content.
|
||||
function _splitBrBlock(block,editor){
|
||||
var doc=editor.getDoc();
|
||||
if(!block||!doc)return null;
|
||||
if(!block.querySelector('br'))return null;
|
||||
var tagName=block.nodeName.toLowerCase();
|
||||
// Only split paragraph-level blocks
|
||||
if(!/^(p|h[1-6])$/.test(tagName))return null;
|
||||
var children=Array.prototype.slice.call(block.childNodes);
|
||||
var lines=[];
|
||||
var current=[];
|
||||
children.forEach(function(node){
|
||||
if(node.nodeName==='BR'&&!node.getAttribute('data-mce-bogus')){
|
||||
lines.push(current);
|
||||
current=[];
|
||||
}else{
|
||||
current.push(node);
|
||||
}
|
||||
});
|
||||
lines.push(current);
|
||||
if(lines.length<=1)return null;
|
||||
var parent=block.parentNode;
|
||||
if(!parent)return null;
|
||||
var ref=block.nextSibling;
|
||||
var newBlocks=lines.map(function(nodes){
|
||||
var p=doc.createElement('p');
|
||||
if(!nodes.length||nodes.every(function(n){return n.nodeType===3&&!n.textContent.trim()})){
|
||||
var br=doc.createElement('br');
|
||||
br.setAttribute('data-mce-bogus','1');
|
||||
p.appendChild(br);
|
||||
}else{
|
||||
nodes.forEach(function(n){p.appendChild(n)});
|
||||
}
|
||||
return p;
|
||||
});
|
||||
newBlocks.forEach(function(p){parent.insertBefore(p,ref)});
|
||||
parent.removeChild(block);
|
||||
return newBlocks;
|
||||
}
|
||||
editor.on('NodeChange',function(){
|
||||
if(_tinymceSuppressEdits)return;
|
||||
var sel=editor.selection;
|
||||
if(!sel)return;
|
||||
var node=sel.getNode();
|
||||
if(!node)return;
|
||||
// Walk up to find the block ancestor
|
||||
var body=editor.getBody();
|
||||
var block=node;
|
||||
while(block&&block!==body&&block.parentNode!==body){
|
||||
block=block.parentNode;
|
||||
}
|
||||
if(!block||block===body)return;
|
||||
// Only act on <p> elements with <br> separators (linebreak mode content)
|
||||
if(block.nodeName!=='P')return;
|
||||
if(!block.querySelector('br'))return;
|
||||
// Temporarily suppress edits so this DOM change doesn't mark the note dirty
|
||||
var prev=_tinymceSuppressEdits;
|
||||
_tinymceSuppressEdits=true;
|
||||
try{
|
||||
// Find which line the caret is on before splitting
|
||||
var caretContainer=sel.getRng()&&sel.getRng().startContainer;
|
||||
var newBlocks=_splitBrBlock(block,editor);
|
||||
if(newBlocks&&caretContainer){
|
||||
// Move caret to the correct new block
|
||||
var targetBlock=null;
|
||||
newBlocks.forEach(function(p){
|
||||
if(!targetBlock&&(p===caretContainer||p.contains(caretContainer))){
|
||||
targetBlock=p;
|
||||
}
|
||||
});
|
||||
// Fallback: use the block that contains text matching what was at caret
|
||||
if(!targetBlock)targetBlock=newBlocks[0];
|
||||
var rng=editor.getDoc().createRange();
|
||||
rng.setStart(targetBlock,0);
|
||||
rng.collapse(true);
|
||||
sel.setRng(rng);
|
||||
}
|
||||
}finally{
|
||||
_tinymceSuppressEdits=prev;
|
||||
}
|
||||
});
|
||||
// Text-expander: after text is committed to the iframe DOM, check the
|
||||
// caret suffix for a trigger and expand. Runs on keyup so the just-typed
|
||||
// character is present. Guarded to rendered mode + text triggers only.
|
||||
|
|
@ -2331,7 +2540,7 @@ function initCM(host,content){
|
|||
}
|
||||
}
|
||||
var _editorMode='markdown';
|
||||
function syncEditorModeButtons(){var mode=_editorMode||'rich';var isMd=mode==='markdown'||mode==='md';document.querySelectorAll('#markdown-toggle').forEach(function(btn){btn.classList.toggle('active',isMd)});document.querySelectorAll('#preview-toggle').forEach(function(btn){btn.classList.toggle('active',!isMd)});var mMd=document.getElementById('mobile-md-toggle');var mPv=document.getElementById('mobile-preview-toggle');if(mMd)mMd.classList.toggle('active',isMd);if(mPv)mPv.classList.toggle('active',!isMd);document.body.classList.toggle('mobile-markdown-mode',inMobileEditor()&&isMd);document.body.classList.toggle('editor-markdown-mode',isMd);document.body.classList.toggle('editor-rich-mode',!isMd)}
|
||||
function syncEditorModeButtons(){var mode=_editorMode||'rich';var isMd=mode==='markdown'||mode==='md';document.querySelectorAll('#markdown-toggle').forEach(function(btn){btn.classList.toggle('active',isMd)});document.querySelectorAll('#preview-toggle').forEach(function(btn){btn.classList.toggle('active',!isMd)});var mMd=document.getElementById('mobile-md-toggle');var mPv=document.getElementById('mobile-preview-toggle');if(mMd)mMd.classList.toggle('active',isMd);if(mPv)mPv.classList.toggle('active',!isMd);document.body.classList.toggle('mobile-markdown-mode',inMobileEditor()&&isMd);document.body.classList.toggle('editor-markdown-mode',isMd);document.body.classList.toggle('editor-rich-mode',!isMd);if(isMd&&typeof closeExportMenu==='function')closeExportMenu()}
|
||||
function applyEditorModeVisibility(mode,opts){
|
||||
opts=opts||{};
|
||||
var isMd=mode==='markdown'||mode==='md';
|
||||
|
|
@ -3018,7 +3227,7 @@ function mountMarkdownEditor(content){
|
|||
if(_cmView.focus)_cmView.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'){_log('esc:keydown editorMode='+_editorMode+' marks='+_searchMarks.length+' cmMarks='+(_cmSearchMatches?_cmSearchMatches.length:0));var resViewer=document.getElementById('resource-viewer');if(resViewer&&!resViewer.hidden){_log('esc:resource-viewer visible, closing');_closeResourceViewer();return}if(resViewer)_log('esc:resource-viewer hidden, skip');var codeModal=document.getElementById('code-modal');if(codeModal&&!codeModal.hidden){_log('esc:code-modal visible, closing');closeCodeModal();return}closeFolderContextMenu();closeFolderModal();closeLinkModal();closeNewFolderModal();closeVaultModal();closeHistoryModal();closeEmptyTrashModal();var bar=document.getElementById('search-nav-bar');_log('esc:bar exists='+!!bar+' hidden='+(bar?bar.hidden:'n/a')+' sesActive='+_searchSessionActive()+' navSearchVal='+((document.getElementById('nav-search')||{}).value||''));if(bar&&!bar.hidden){_log('esc:dismissing search-nav-bar');searchNavDismiss();return}if(_searchSessionActive()){_log('esc:session active, dismissing');searchNavDismiss();return}var navSearch=document.getElementById('nav-search');if(navSearch&&navSearch.value){_log('esc:clearing nav-search field');navSearch.value='';htmx.trigger(navSearch,'search-submit');return}_log('esc:no handler matched, fallthrough')}if(!getTA()&&!getPV()&&!getCM())return;if((e.ctrlKey||e.metaKey)&&!e.altKey&&e.code==='Space'){e.preventDefault();requestManualProseCompletion();return}if((e.ctrlKey||e.metaKey)&&e.key==='b'){e.preventDefault();wrapSel('**','**')}if((e.ctrlKey||e.metaKey)&&e.key==='i'){e.preventDefault();wrapSel('*','*')}if((e.ctrlKey||e.metaKey)&&e.key==='f'){e.preventDefault();if(_editorMode==='preview'&&_searchMarks.length){searchNavStep(1)}else{applySearchHighlight()}}});
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'){_log('esc:keydown editorMode='+_editorMode+' marks='+_searchMarks.length+' cmMarks='+(_cmSearchMatches?_cmSearchMatches.length:0));var resViewer=document.getElementById('resource-viewer');if(resViewer&&!resViewer.hidden){_log('esc:resource-viewer visible, closing');_closeResourceViewer();return}if(resViewer)_log('esc:resource-viewer hidden, skip');var codeModal=document.getElementById('code-modal');if(codeModal&&!codeModal.hidden){_log('esc:code-modal visible, closing');closeCodeModal();return}var exportMenu=document.getElementById('export-menu');if(exportMenu&&!exportMenu.hidden){closeExportMenu();return}closeFolderContextMenu();closeFolderModal();closeLinkModal();closeNewFolderModal();closeVaultModal();closeHistoryModal();closeEmptyTrashModal();var bar=document.getElementById('search-nav-bar');_log('esc:bar exists='+!!bar+' hidden='+(bar?bar.hidden:'n/a')+' sesActive='+_searchSessionActive()+' navSearchVal='+((document.getElementById('nav-search')||{}).value||''));if(bar&&!bar.hidden){_log('esc:dismissing search-nav-bar');searchNavDismiss();return}if(_searchSessionActive()){_log('esc:session active, dismissing');searchNavDismiss();return}var navSearch=document.getElementById('nav-search');if(navSearch&&navSearch.value){_log('esc:clearing nav-search field');navSearch.value='';htmx.trigger(navSearch,'search-submit');return}_log('esc:no handler matched, fallthrough')}if(!getTA()&&!getPV()&&!getCM())return;if((e.ctrlKey||e.metaKey)&&!e.altKey&&e.code==='Space'){e.preventDefault();requestManualProseCompletion();return}if((e.ctrlKey||e.metaKey)&&e.key==='b'){e.preventDefault();wrapSel('**','**')}if((e.ctrlKey||e.metaKey)&&e.key==='i'){e.preventDefault();wrapSel('*','*')}if((e.ctrlKey||e.metaKey)&&e.key==='f'){e.preventDefault();if(_editorMode==='preview'&&_searchMarks.length){searchNavStep(1)}else{applySearchHighlight()}}});
|
||||
document.addEventListener('click',function(e){var menu=document.getElementById('folder-context-menu');if(menu&&!menu.hidden&&!menu.contains(e.target))closeFolderContextMenu()});
|
||||
function highlightCodeBlocks(container){if(!window.hljs||!container)return;container.querySelectorAll('pre code[class*="language-"]').forEach(function(el){if(el.dataset.highlighted)return;window.hljs.highlightElement(el)})}
|
||||
function ensureEditableAfterPre(pv){if(!pv)return;var pres=pv.querySelectorAll('pre');pres.forEach(function(pre){var next=pre.nextElementSibling;if(!next){var p=document.createElement('p');p.innerHTML='<br>';p.dataset.pvTrail='1';pv.appendChild(p)}})}
|
||||
|
|
@ -3697,15 +3906,18 @@ function confirmLogout(event){
|
|||
};
|
||||
// Context menu (long-press on note rows)
|
||||
var _ctxNoteId=null,_ctxNoteTitle=null,_ctxLongPressTimer=null;
|
||||
function mobileCtxOpen(noteId,noteTitle){
|
||||
function mobileCtxOpen(noteId,noteTitle,opts){
|
||||
opts=opts||{};
|
||||
_ctxNoteId=noteId;_ctxNoteTitle=noteTitle;
|
||||
var backdrop=document.getElementById('mobile-ctx-backdrop');
|
||||
var sheet=document.getElementById('mobile-ctx-sheet');
|
||||
var titleEl=document.getElementById('mobile-ctx-title');
|
||||
var metaEl=document.getElementById('mobile-ctx-meta');
|
||||
var exportBtn=document.getElementById('mobile-ctx-export');
|
||||
var moveBtn=document.getElementById('mobile-ctx-move');
|
||||
var delBtn=document.getElementById('mobile-ctx-delete');
|
||||
if(titleEl)titleEl.textContent=noteTitle||'Untitled';
|
||||
if(exportBtn)exportBtn.style.display=(opts.isEditorContext&&!_isMarkdownModeActive())?'':'none';
|
||||
if(metaEl){
|
||||
var mbody=document.getElementById('mobile-editor-body');
|
||||
var metaSrc=mbody?mbody.querySelector('#note-meta'):null;
|
||||
|
|
@ -3783,7 +3995,14 @@ function confirmLogout(event){
|
|||
var form=activeEditorForm();
|
||||
if(!form)return;
|
||||
var titleInput=form.querySelector('.editor-title');
|
||||
mobileCtxOpen(form.dataset.noteId||_state.noteId,(titleInput&&titleInput.textContent)||document.getElementById('mobile-editor-title')&&document.getElementById('mobile-editor-title').textContent||'Untitled');
|
||||
mobileCtxOpen(form.dataset.noteId||_state.noteId,(titleInput&&titleInput.textContent)||document.getElementById('mobile-editor-title')&&document.getElementById('mobile-editor-title').textContent||'Untitled',{isEditorContext:true});
|
||||
};
|
||||
window.mobileCtxExport=function(){
|
||||
mobileCtxClose();
|
||||
setTimeout(function(){
|
||||
var anchor=document.getElementById('mobile-editor-menu-btn');
|
||||
toggleExportMenu(anchor);
|
||||
},50);
|
||||
};
|
||||
function wireNoteRowLongPress(container){
|
||||
if(!container)return;
|
||||
|
|
@ -5050,4 +5269,10 @@ window.isEncryptedBody=isEncryptedBody;
|
|||
window.mobileSyncTitle=mobileSyncTitle;
|
||||
window.mobileSyncTitleAndSave=mobileSyncTitleAndSave;
|
||||
window.mobileTitleInput=function(){_titleManual=true}; // called oninput on #mobile-editor-title
|
||||
window.toggleExportMenu=toggleExportMenu;
|
||||
window.closeExportMenu=closeExportMenu;
|
||||
window.exportNoteAsMarkdown=exportNoteAsMarkdown;
|
||||
window.exportNoteAsHtml=exportNoteAsHtml;
|
||||
window.exportNoteAsDocx=exportNoteAsDocx;
|
||||
window.exportNoteAsPdf=exportNoteAsPdf;
|
||||
})(); // end main IIFE
|
||||
|
|
|
|||
13216
public/html-docx.js
Normal file
13216
public/html-docx.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1398,7 +1398,7 @@ body.nav-collapsed .editor-titlebar { padding-left: 42px; }
|
|||
|
||||
/* Toolbar */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 2px 12px 4px;
|
||||
|
|
@ -1439,6 +1439,34 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Export menu (rendered mode only) */
|
||||
.editor-markdown-mode #export-note-btn { display: none; }
|
||||
.export-menu {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
min-width: 160px;
|
||||
background: var(--bg-editor);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
}
|
||||
.export-menu:not([hidden]) { display: flex; }
|
||||
.export-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
.export-menu button:hover { background: var(--bg-hover); }
|
||||
.export-menu .export-menu-cancel { color: var(--text-muted); margin-top: 2px; border-top: 1px solid var(--border); border-radius: 0 0 4px 4px; }
|
||||
|
||||
/* Editor body (hidden textarea, used as sync target for form submission) */
|
||||
.editor-body {
|
||||
display: none;
|
||||
|
|
@ -1504,7 +1532,8 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
/* TinyMCE theme integration — toolbar tinted per-theme via accent color */
|
||||
.tox .tox-toolbar-overlord,
|
||||
.tox .tox-toolbar,
|
||||
.tox .tox-toolbar__primary {
|
||||
.tox .tox-toolbar__primary,
|
||||
.tox .tox-toolbar__overflow {
|
||||
background-color: color-mix(in srgb, var(--accent) 10%, var(--bg)) !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
|
@ -1810,6 +1839,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
.editor-preview hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
|
||||
.editor-preview ul, .editor-preview ol { padding-left: 1.5em; margin: 0.5em 0; }
|
||||
.editor-preview li { margin: 0.25em 0; }
|
||||
.editor-preview table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
||||
.editor-preview th, .editor-preview td { border: 3px solid var(--border); padding: 6px 10px; text-align: left; }
|
||||
.editor-preview th { background: var(--bg-hover); font-weight: bold; }
|
||||
.editor-preview .md-checkbox {
|
||||
margin: 0.25em 0;
|
||||
padding-left: 0.9em;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ function makeCtxWithToolbar() {
|
|||
function insertStamp(kind){_calls.push(['insertStamp',kind])}
|
||||
function openUploadModal(){_calls.push(['openUploadModal'])}
|
||||
function openHistoryModal(id){_calls.push(['openHistoryModal',id])}
|
||||
function toggleExportMenu(){_calls.push(['toggleExportMenu'])}
|
||||
`, ctx);
|
||||
[
|
||||
'_isMarkdownModeActive',
|
||||
|
|
@ -105,6 +106,7 @@ test('markdown toolbar click path: every toolbar button dispatches expected mark
|
|||
['Image', ['insertImg']],
|
||||
['Upload file', ['openUploadModal']],
|
||||
['Note history', ['openHistoryModal', 'n1']],
|
||||
['Export note', ['toggleExportMenu']],
|
||||
];
|
||||
|
||||
const buttonCount = vm.runInContext("document.querySelectorAll('#editor-toolbar button[title]').length", ctx);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue