diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 3c16ab6d..407f6286 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -513,7 +513,6 @@ static void (^MPGetPreviewLoadingCompletionHandler(MPDocument *doc))() }; } - /** * Issue #436: Scans a single line for a fenced-code-block marker (a run of 3+ backticks or * tildes, allowing 0-3 leading spaces). Returns YES and reports the marker character, its @@ -675,7 +674,6 @@ - (NSUInteger)mathJaxRenderGeneration return _mathJaxRenderGeneration; } - #pragma mark - Override - (instancetype)init @@ -2167,6 +2165,7 @@ - (void)renderer:(MPRenderer *)renderer didProduceHTMLOutput:(NSString *)html @" body.innerHTML = html;" @" if(window.Prism){Prism.highlightAll();}" @" if(typeof window.macdownInitTaskList==='function'){window.macdownInitTaskList();}" + @" if(typeof window.macdownInitTableResize==='function'){window.macdownInitTableResize();}" @" if(window.MathJax&&MathJax.Hub){" @" MathJax.Hub.Queue(['Typeset',MathJax.Hub]);" @" MathJax.Hub.Queue(function(){" @@ -4715,6 +4714,19 @@ - (void)document:(NSDocument *)doc didPrint:(BOOL)ok context:(void *)context #pragma mark - Interactive Checkbox Support (Issue #269) +- (NSDictionary *)queryItemsByNameForURL:(NSURL *)url +{ + NSURLComponents *components = + [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + NSMutableDictionary *items = [NSMutableDictionary dictionary]; + for (NSURLQueryItem *item in components.queryItems) + { + if (item.name.length && item.value) + items[item.name] = item.value; + } + return items; +} + /** * Handle the checkbox toggle URL from the preview. * URL format: x-macdown-checkbox://toggle/ @@ -4724,17 +4736,7 @@ - (void)handleCheckboxToggle:(NSURL *)url if (![url.host isEqualToString:@"toggle"]) return; - NSURLComponents *components = - [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; - NSString *token = nil; - for (NSURLQueryItem *item in components.queryItems) - { - if ([item.name isEqualToString:@"token"]) - { - token = item.value; - break; - } - } + NSString *token = [self queryItemsByNameForURL:url][@"token"]; if (!token.length || ![token isEqualToString:self.renderer.checkboxBridgeToken]) { diff --git a/MacDown/Code/Document/MPRenderer.m b/MacDown/Code/Document/MPRenderer.m index 1a67a413..de877e18 100644 --- a/MacDown/Code/Document/MPRenderer.m +++ b/MacDown/Code/Document/MPRenderer.m @@ -512,11 +512,11 @@ NS_INLINE void MPFreeHTMLRenderer(hoedown_renderer *htmlRenderer) NS_INLINE NSString *MPPreviewHeadTags(NSString *checkboxBridgeToken) { NSString *csp = MPEscapeHTMLAttribute(MPPreviewContentSecurityPolicy()); - NSString *token = MPEscapeHTMLAttribute(checkboxBridgeToken); + NSString *checkboxToken = MPEscapeHTMLAttribute(checkboxBridgeToken); return [NSString stringWithFormat: @"\n" "", - csp, token]; + csp, checkboxToken]; } @@ -686,6 +686,10 @@ - (NSArray *)scripts NSURL *url = MPExtensionURL(@"tasklist", @"js"); [scripts addObject:[MPScript javaScriptWithURL:url]]; } + { + NSURL *url = MPExtensionURL(@"table-resize", @"js"); + [scripts addObject:[MPScript javaScriptWithURL:url]]; + } if ([d rendererHasSyntaxHighlighting:self]) { [scripts addObjectsFromArray:self.prismScripts]; @@ -825,13 +829,15 @@ - (void)render id delegate = self.delegate; NSString *body = self.currentHtml; + NSString *previewBody = body ?: @""; NSString *title = [self.dataSource rendererHTMLTitle:self]; if (!self.checkboxBridgeToken.length) self.checkboxBridgeToken = NSUUID.UUID.UUIDString; NSString *headTags = MPPreviewHeadTags(self.checkboxBridgeToken); NSString *html = MPGetHTML( - title, headTags, body, self.stylesheets, MPAssetFullLink, + title, headTags, previewBody, + self.stylesheets, MPAssetFullLink, self.scripts, MPAssetFullLink); // Issue #110 / #318: Apply cache-busting version stamps to local resource diff --git a/MacDown/Resources/Extensions/export.css b/MacDown/Resources/Extensions/export.css index 3a01eabc..751e5ade 100644 --- a/MacDown/Resources/Extensions/export.css +++ b/MacDown/Resources/Extensions/export.css @@ -28,6 +28,13 @@ td, th { overflow-wrap: break-word; } +/* Table headers - keep short labels such as "v1-A" on one line */ +th { + white-space: nowrap; + word-break: normal; + overflow-wrap: normal; +} + /* Wide tables - give every theme a horizontal scroll container so columns keep * their natural width instead of compressing to the pane width (Issue #432). * @@ -52,6 +59,44 @@ td, th { } } +.macdown-resizable-table { + display: table; + table-layout: fixed; + width: max-content; + max-width: none; +} + +.macdown-table-resizable { + position: relative; + padding-right: 14px; +} + +.macdown-table-resize-handle { + bottom: 0; + cursor: col-resize; + position: absolute; + right: -4px; + top: 0; + width: 8px; + z-index: 2; +} + +.macdown-table-resize-handle::after { + background: currentColor; + bottom: 4px; + content: ""; + opacity: 0.25; + position: absolute; + right: 3px; + top: 4px; + width: 1px; +} + +.macdown-table-resize-handle:hover::after, +.macdown-table-resizing .macdown-table-resize-handle::after { + opacity: 0.7; +} + /* Blockquotes - ensure quoted content wraps */ blockquote { word-break: break-word; diff --git a/MacDown/Resources/Extensions/table-resize.js b/MacDown/Resources/Extensions/table-resize.js new file mode 100644 index 00000000..e198bfc0 --- /dev/null +++ b/MacDown/Resources/Extensions/table-resize.js @@ -0,0 +1,174 @@ +/** + * Live-preview Markdown table column resizing. + * + * Widths are ephemeral: kept only in this script's in-memory sessionWidths + * map, never written back to the document or persisted to disk. They survive + * incremental preview updates (typing) because the preview's JS context + * persists across DOM replacement, but reset on a full preview reload. + * This script only runs in the editor preview; exports do not include it. + */ +(function () { + var MIN_WIDTH = 48; + var resizing = null; + var sessionWidths = {}; + + function headerText(table) { + var cells = table.querySelectorAll('thead th'); + if (!cells.length) { + cells = table.querySelectorAll('tr:first-child th, tr:first-child td'); + } + var parts = []; + for (var i = 0; i < cells.length; i++) { + parts.push((cells[i].textContent || '').replace(/\s+/g, ' ').trim()); + } + return parts.join('|'); + } + + function hashString(value) { + var hash = 2166136261; + for (var i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); + } + return (hash >>> 0).toString(16); + } + + function tableKey(table, index) { + return index + ':' + hashString(headerText(table)); + } + + function ensureColgroup(table, columnCount) { + var colgroup = table.querySelector('colgroup'); + if (!colgroup) { + colgroup = document.createElement('colgroup'); + table.insertBefore(colgroup, table.firstChild); + } + while (colgroup.children.length < columnCount) { + colgroup.appendChild(document.createElement('col')); + } + while (colgroup.children.length > columnCount) { + colgroup.removeChild(colgroup.lastChild); + } + return colgroup; + } + + function setColumnWidth(col, width) { + col.style.width = Math.max(MIN_WIDTH, Math.round(width)) + 'px'; + } + + function headerCells(table) { + var cells = table.querySelectorAll('thead th'); + if (cells.length) { + return cells; + } + return table.querySelectorAll('tr:first-child th, tr:first-child td'); + } + + function teardownHandles(table) { + var oldHandles = table.querySelectorAll('.macdown-table-resize-handle'); + for (var i = 0; i < oldHandles.length; i++) { + oldHandles[i].parentNode.removeChild(oldHandles[i]); + } + var oldCells = table.querySelectorAll('.macdown-table-resizable'); + for (var j = 0; j < oldCells.length; j++) { + oldCells[j].classList.remove('macdown-table-resizable'); + } + } + + function initTable(table, index) { + var cells = headerCells(table); + if (!cells.length) { + return; + } + + teardownHandles(table); + + var key = tableKey(table, index); + table.setAttribute('data-macdown-table-key', key); + table.classList.add('macdown-resizable-table'); + + var colgroup = ensureColgroup(table, cells.length); + var saved = sessionWidths[key] || {}; + for (var i = 0; i < cells.length; i++) { + var savedWidth = saved[String(i)]; + if (savedWidth !== undefined && savedWidth !== null) { + setColumnWidth(colgroup.children[i], savedWidth); + } + } + + for (var column = 0; column < cells.length; column++) { + (function (cell, columnIndex) { + cell.classList.add('macdown-table-resizable'); + var handle = document.createElement('span'); + handle.className = 'macdown-table-resize-handle'; + handle.setAttribute('role', 'separator'); + handle.setAttribute('aria-orientation', 'vertical'); + handle.setAttribute('title', 'Resize column'); + + handle.addEventListener('mousedown', function (event) { + event.preventDefault(); + event.stopPropagation(); + var col = colgroup.children[columnIndex]; + var rect = cell.getBoundingClientRect(); + resizing = { + table: key, + column: columnIndex, + col: col, + startX: event.clientX, + startWidth: parseFloat(col.style.width) || rect.width + }; + document.documentElement.classList.add('macdown-table-resizing'); + }); + + handle.addEventListener('dblclick', function (event) { + event.preventDefault(); + event.stopPropagation(); + colgroup.children[columnIndex].style.width = ''; + var tableWidths = sessionWidths[key]; + if (tableWidths) { + delete tableWidths[String(columnIndex)]; + if (!Object.keys(tableWidths).length) { + delete sessionWidths[key]; + } + } + }); + + cell.appendChild(handle); + })(cells[column], column); + } + } + + document.addEventListener('mousemove', function (event) { + if (!resizing) { + return; + } + var width = Math.max(MIN_WIDTH, resizing.startWidth + event.clientX - resizing.startX); + setColumnWidth(resizing.col, width); + }); + + document.addEventListener('mouseup', function () { + if (!resizing) { + return; + } + var width = parseFloat(resizing.col.style.width); + if (isFinite(width)) { + var tableWidths = sessionWidths[resizing.table]; + if (!tableWidths) { + tableWidths = {}; + sessionWidths[resizing.table] = tableWidths; + } + tableWidths[String(resizing.column)] = width; + } + resizing = null; + document.documentElement.classList.remove('macdown-table-resizing'); + }); + + window.macdownInitTableResize = function () { + var tables = document.querySelectorAll('table'); + for (var i = 0; i < tables.length; i++) { + initTable(tables[i], i); + } + }; + + window.macdownInitTableResize(); +})(); diff --git a/MacDownTests/MPHTMLExportTests.m b/MacDownTests/MPHTMLExportTests.m index 9ab68f9b..474a7049 100644 --- a/MacDownTests/MPHTMLExportTests.m +++ b/MacDownTests/MPHTMLExportTests.m @@ -161,6 +161,19 @@ - (void)testExportCSSContainsListAndTableBreaking @"Should target th elements"); } +- (void)testExportCSSKeepsTableHeadersOnOneLine +{ + NSString *cssContent = [self exportCSSContent]; + XCTAssertNotNil(cssContent, @"export.css should have content"); + + XCTAssertTrue([cssContent containsString:@"white-space: nowrap"], + @"Table headers should not wrap short phase labels"); + XCTAssertTrue([cssContent containsString:@"word-break: normal"], + @"Table headers should preserve normal word breaking"); + XCTAssertTrue([cssContent containsString:@"overflow-x: auto"], + @"Wide tables should scroll horizontally instead of squeezing columns"); +} + - (void)testExportCSSContainsBlockquoteAndDescriptionBreaking { NSString *cssContent = [self exportCSSContent]; diff --git a/MacDownTests/MPRendererEdgeCaseTests.m b/MacDownTests/MPRendererEdgeCaseTests.m index 51d679b7..ebcdfba5 100644 --- a/MacDownTests/MPRendererEdgeCaseTests.m +++ b/MacDownTests/MPRendererEdgeCaseTests.m @@ -177,6 +177,12 @@ - (void)testPreviewRenderIncludesContentSecurityPolicyAndCheckboxToken @"CSP should whitelist only bundled scripts and the MathJax CDN"); XCTAssertTrue([html containsString:@"name=\"macdown-checkbox-token\""], @"Preview HTML should include a checkbox bridge token"); + XCTAssertTrue([html containsString:@"table-resize.js"], + @"Preview HTML should include live table resizing behavior"); + XCTAssertFalse([html containsString:@"name=\"macdown-table-layout-token\""], + @"Ephemeral table resizing uses no native bridge token"); + XCTAssertFalse([html containsString:@"id=\"macdown-table-layouts\""], + @"Ephemeral table resizing uses no injected layout data"); XCTAssertTrue(self.renderer.checkboxBridgeToken.length > 0, @"Renderer should expose the active checkbox bridge token"); } @@ -212,6 +218,8 @@ - (void)testHTMLExportDoesNotIncludePreviewOnlySecurityMetaTags @"Preview-only CSP should not be embedded into exports"); XCTAssertFalse([html containsString:@"macdown-checkbox-token"], @"Preview-only checkbox tokens should not leak into exports"); + XCTAssertFalse([html containsString:@"table-resize.js"], + @"Preview-only table resizing script should not leak into exports"); }