From d47e91315b040bc8b2a081b9e2c47c9a694e3f22 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 15 Jun 2026 16:20:27 -0700 Subject: [PATCH] Add draggable table column resizing --- .gitignore | 2 + MacDown/Code/Document/MPDocument.m | 254 ++++++++++++++++++- MacDown/Code/Document/MPRenderer.h | 4 + MacDown/Code/Document/MPRenderer.m | 45 +++- MacDown/Resources/Extensions/export.css | 57 ++++- MacDown/Resources/Extensions/table-resize.js | 192 ++++++++++++++ MacDownTests/MPHTMLExportTests.m | 13 + MacDownTests/MPRendererEdgeCaseTests.m | 35 +++ MacDownTests/MPRendererTestHelpers.h | 1 + MacDownTests/MPRendererTestHelpers.m | 6 + MacDownTests/MPScrollSyncTests.m | 162 ++++++++++++ Podfile.lock | 2 +- 12 files changed, 752 insertions(+), 21 deletions(-) create mode 100644 MacDown/Resources/Extensions/table-resize.js diff --git a/.gitignore b/.gitignore index cacea3c4..222825a4 100644 --- a/.gitignore +++ b/.gitignore @@ -191,6 +191,8 @@ MacDown/Resources/Prism/* # Bundler binstubs bin/ +.bundle/ +vendor/bundle/ # Claude Code plugins (installed via SessionStart hook) .claude/plugins/ diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 75cc0fea..badf27ac 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -37,6 +37,10 @@ #import static NSString * const kMPDefaultAutosaveName = @"Untitled"; +static NSString * const kMPTableLayoutScheme = @"x-macdown-table-layout"; +static NSString * const kMPTableLayoutSetAction = @"set"; +static NSString * const kMPTableLayoutResetAction = @"reset"; +static const CGFloat kMPMinimumTableColumnWidth = 48.0; NS_INLINE NSString *MPEditorPreferenceKeyWithValueKey(NSString *key) @@ -237,6 +241,9 @@ typedef NS_ENUM(NSUInteger, MPScrollOwner) { @property (strong) NSArray *editorHeaderLocations; @property (nonatomic) MPScrollOwner scrollOwner; // Issue #342: Scroll ownership model @property (nonatomic) NSTimeInterval lastWordCountUpdate; // Issue #294: Throttle timestamp +@property (nonatomic, strong) NSMutableDictionary *> *tableLayouts; +@property (copy) NSString *tableLayoutDocumentKey; +@property (nonatomic, strong) NSURL *tableLayoutsFileURL; // Issue #290: File watching for auto-reload @property (strong) MPFileWatcher *fileWatcher; @@ -258,6 +265,7 @@ - (void)scaleWebview; - (void)syncScrollers; - (void)syncScrollersReverse; - (void)updateHeaderLocations; +- (void)handleTableLayoutURL:(NSURL *)url; - (void)validateHeaderLocationAlignment; - (void)invokeRenderCompletionHandlers; - (void)willStartPreviewLiveScroll:(NSNotification *)notification; @@ -336,6 +344,45 @@ static void (^MPGetPreviewLoadingCompletionHandler(MPDocument *doc))() }; } +NS_INLINE NSURL *MPDefaultTableLayoutsFileURL(void) +{ + NSFileManager *manager = [NSFileManager defaultManager]; + NSURL *supportURL = [[manager URLsForDirectory:NSApplicationSupportDirectory + inDomains:NSUserDomainMask] firstObject]; + supportURL = [supportURL URLByAppendingPathComponent:@"MacDown 3000" + isDirectory:YES]; + supportURL = [supportURL URLByAppendingPathComponent:@"Table Layouts" + isDirectory:YES]; + return [supportURL URLByAppendingPathComponent:@"table-layouts.json"]; +} + +NS_INLINE NSString *MPTableLayoutDocumentKeyForURL(NSURL *url) +{ + if (!url) + return nil; + if (url.isFileURL) + return url.URLByStandardizingPath.path; + return url.absoluteString; +} + +NS_INLINE NSMutableDictionary *MPMutableDictionaryFromJSONObject(id object) +{ + if (![object isKindOfClass:[NSDictionary class]]) + return [NSMutableDictionary dictionary]; + + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + [(NSDictionary *)object enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) { + if ([key isKindOfClass:[NSString class]]) + { + if ([value isKindOfClass:[NSDictionary class]]) + result[key] = MPMutableDictionaryFromJSONObject(value); + else + result[key] = value; + } + }]; + return result; +} + @implementation MPDocument @@ -427,6 +474,119 @@ - (NSUInteger)mathJaxRenderGeneration return _mathJaxRenderGeneration; } +- (NSURL *)tableLayoutsFileURL +{ + if (!_tableLayoutsFileURL) + _tableLayoutsFileURL = MPDefaultTableLayoutsFileURL(); + return _tableLayoutsFileURL; +} + +- (NSMutableDictionary *)tableLayouts +{ + if (!_tableLayouts) + _tableLayouts = [NSMutableDictionary dictionary]; + return _tableLayouts; +} + +- (NSString *)currentTableLayoutDocumentKey +{ + return MPTableLayoutDocumentKeyForURL(self.fileURL); +} + +- (NSMutableDictionary *)tableLayoutStore +{ + NSData *data = [NSData dataWithContentsOfURL:self.tableLayoutsFileURL]; + if (!data.length) + return [NSMutableDictionary dictionary]; + + id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]; + return MPMutableDictionaryFromJSONObject(json); +} + +- (void)loadTableLayoutsIfNeeded +{ + NSString *documentKey = [self currentTableLayoutDocumentKey]; + NSString *previousDocumentKey = self.tableLayoutDocumentKey; + if ((!documentKey && !previousDocumentKey) + || [documentKey isEqualToString:previousDocumentKey]) + return; + + BOOL shouldMigrateSessionLayouts = (documentKey.length + && self.tableLayouts.count > 0); + self.tableLayoutDocumentKey = documentKey; + + if (shouldMigrateSessionLayouts) + { + [self persistTableLayouts]; + return; + } + + [self.tableLayouts removeAllObjects]; + + if (!documentKey.length) + return; + + NSDictionary *store = [self tableLayoutStore]; + NSDictionary *documents = store[@"documents"]; + NSDictionary *layouts = documents[documentKey]; + if (![layouts isKindOfClass:[NSDictionary class]]) + return; + + [layouts enumerateKeysAndObjectsUsingBlock:^(id tableKey, id columns, BOOL *stop) { + if (![tableKey isKindOfClass:[NSString class]] + || ![columns isKindOfClass:[NSDictionary class]]) + return; + + NSMutableDictionary *table = [NSMutableDictionary dictionary]; + [(NSDictionary *)columns enumerateKeysAndObjectsUsingBlock:^(id column, id width, BOOL *innerStop) { + if ([column isKindOfClass:[NSString class]] + && [width respondsToSelector:@selector(doubleValue)] + && [width doubleValue] >= kMPMinimumTableColumnWidth) + table[column] = @([width doubleValue]); + }]; + if (table.count) + self.tableLayouts[tableKey] = table; + }]; +} + +- (void)persistTableLayouts +{ + NSString *documentKey = [self currentTableLayoutDocumentKey]; + if (!documentKey.length) + return; + + NSMutableDictionary *store = [self tableLayoutStore]; + NSMutableDictionary *documents = MPMutableDictionaryFromJSONObject(store[@"documents"]); + store[@"documents"] = documents; + + if (self.tableLayouts.count) + documents[documentKey] = [self.tableLayouts copy]; + else + [documents removeObjectForKey:documentKey]; + + NSURL *directoryURL = [self.tableLayoutsFileURL URLByDeletingLastPathComponent]; + [[NSFileManager defaultManager] createDirectoryAtURL:directoryURL + withIntermediateDirectories:YES + attributes:nil + error:NULL]; + NSData *data = [NSJSONSerialization dataWithJSONObject:store + options:NSJSONWritingPrettyPrinted + error:NULL]; + if (data) + [data writeToURL:self.tableLayoutsFileURL atomically:YES]; +} + +- (NSString *)rendererTableLayoutsJSON:(MPRenderer *)renderer +{ + [self loadTableLayoutsIfNeeded]; + NSData *data = [NSJSONSerialization dataWithJSONObject:self.tableLayouts + options:0 + error:NULL]; + if (!data) + return @"{}"; + return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}"; +} + #pragma mark - Override @@ -1173,6 +1333,12 @@ - (void)webView:(WebView *)webView [self handleCheckboxToggle:url]; return; } + if ([url.scheme isEqualToString:kMPTableLayoutScheme]) + { + [listener ignore]; + [self handleTableLayoutURL:url]; + return; + } switch ([information[WebActionNavigationTypeKey] integerValue]) { @@ -1443,6 +1609,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(){" @@ -3018,26 +3185,91 @@ - (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/ + * Handle table layout URLs from the live preview. + * URL format: + * x-macdown-table-layout://set?token=&table=&column=&width= + * x-macdown-table-layout://reset?token=&table=&column= */ -- (void)handleCheckboxToggle:(NSURL *)url +- (void)handleTableLayoutURL:(NSURL *)url { - if (![url.host isEqualToString:@"toggle"]) + NSString *action = url.host; + if (![action isEqualToString:kMPTableLayoutSetAction] + && ![action isEqualToString:kMPTableLayoutResetAction]) return; - NSURLComponents *components = - [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; - NSString *token = nil; - for (NSURLQueryItem *item in components.queryItems) + NSDictionary *items = [self queryItemsByNameForURL:url]; + NSString *token = items[@"token"]; + if (!token.length + || ![token isEqualToString:self.renderer.tableLayoutBridgeToken]) { - if ([item.name isEqualToString:@"token"]) + NSLog(@"MacDown: Ignored unauthorized table layout URL: %@", url); + return; + } + + NSString *tableKey = items[@"table"]; + NSString *column = items[@"column"]; + if (!tableKey.length || !column.length) + return; + + NSCharacterSet *digits = [NSCharacterSet decimalDigitCharacterSet]; + if ([[column stringByTrimmingCharactersInSet:digits] length] != 0) + return; + + [self loadTableLayoutsIfNeeded]; + if ([action isEqualToString:kMPTableLayoutSetAction]) + { + double width = [items[@"width"] doubleValue]; + if (width < kMPMinimumTableColumnWidth) + return; + NSNumber *newWidth = @(round(width)); + NSMutableDictionary *table = self.tableLayouts[tableKey]; + if (!table) { - token = item.value; - break; + table = [NSMutableDictionary dictionary]; + self.tableLayouts[tableKey] = table; } + if ([table[column] isEqualToNumber:newWidth]) + return; + table[column] = newWidth; + } + else + { + NSMutableDictionary *table = self.tableLayouts[tableKey]; + if (!table[column]) + return; + [table removeObjectForKey:column]; + if (!table.count) + [self.tableLayouts removeObjectForKey:tableKey]; } + + if ([self currentTableLayoutDocumentKey].length) + [self persistTableLayouts]; +} + +/** + * Handle the checkbox toggle URL from the preview. + * URL format: x-macdown-checkbox://toggle/ + */ +- (void)handleCheckboxToggle:(NSURL *)url +{ + if (![url.host isEqualToString:@"toggle"]) + return; + + NSString *token = [self queryItemsByNameForURL:url][@"token"]; if (!token.length || ![token isEqualToString:self.renderer.checkboxBridgeToken]) { diff --git a/MacDown/Code/Document/MPRenderer.h b/MacDown/Code/Document/MPRenderer.h index ab1bb39c..36b99882 100644 --- a/MacDown/Code/Document/MPRenderer.h +++ b/MacDown/Code/Document/MPRenderer.h @@ -25,6 +25,7 @@ typedef NS_ENUM(NSUInteger, MPCodeBlockAccessoryType) @property (weak) id dataSource; @property (weak) id delegate; @property (nonatomic, copy, readonly) NSString *checkboxBridgeToken; +@property (nonatomic, copy, readonly) NSString *tableLayoutBridgeToken; - (void)parseAndRenderNow; - (void)parseAndRenderLater; @@ -52,6 +53,9 @@ typedef NS_ENUM(NSUInteger, MPCodeBlockAccessoryType) - (NSString *)rendererMarkdown:(MPRenderer *)renderer; - (NSString *)rendererHTMLTitle:(MPRenderer *)renderer; +@optional +- (NSString *)rendererTableLayoutsJSON:(MPRenderer *)renderer; + @end @protocol MPRendererDelegate diff --git a/MacDown/Code/Document/MPRenderer.m b/MacDown/Code/Document/MPRenderer.m index 9aec3617..79e5cd70 100644 --- a/MacDown/Code/Document/MPRenderer.m +++ b/MacDown/Code/Document/MPRenderer.m @@ -249,7 +249,9 @@ NS_INLINE NSString *MPEscapeHTMLAttribute(NSString *value); NS_INLINE NSString *MPEscapeHTMLText(NSString *value); NS_INLINE NSString *MPPreviewContentSecurityPolicy(void); -NS_INLINE NSString *MPPreviewHeadTags(NSString *checkboxBridgeToken); +NS_INLINE NSString *MPPreviewHeadTags(NSString *checkboxBridgeToken, + NSString *tableLayoutBridgeToken); +NS_INLINE NSString *MPPreviewTableLayoutsTag(NSString *json); NS_INLINE NSString *MPGetHTML( NSString *title, NSString *headTags, NSString *body, NSArray *styles, @@ -335,6 +337,7 @@ @interface MPRenderer () @property BOOL manualRender; @property (copy) NSString *highlightingThemeName; @property (nonatomic, copy, readwrite) NSString *checkboxBridgeToken; +@property (nonatomic, copy, readwrite) NSString *tableLayoutBridgeToken; // Issue #110: Cache-busting timestamps for local resources @property (strong) NSMutableDictionary *resourceTimestamps; @@ -501,14 +504,28 @@ NS_INLINE void MPFreeHTMLRenderer(hoedown_renderer *htmlRenderer) @"script-src 'self' file: https://cdnjs.cloudflare.com 'unsafe-eval'"; } -NS_INLINE NSString *MPPreviewHeadTags(NSString *checkboxBridgeToken) +NS_INLINE NSString *MPPreviewHeadTags(NSString *checkboxBridgeToken, + NSString *tableLayoutBridgeToken) { NSString *csp = MPEscapeHTMLAttribute(MPPreviewContentSecurityPolicy()); - NSString *token = MPEscapeHTMLAttribute(checkboxBridgeToken); + NSString *checkboxToken = MPEscapeHTMLAttribute(checkboxBridgeToken); + NSString *tableToken = MPEscapeHTMLAttribute(tableLayoutBridgeToken); return [NSString stringWithFormat: @"\n" - "", - csp, token]; + "\n" + "", + csp, checkboxToken, tableToken]; +} + +NS_INLINE NSString *MPPreviewTableLayoutsTag(NSString *json) +{ + if (!json.length) + json = @"{}"; + json = [json stringByReplacingOccurrencesOfString:@"%@\n", + json]; } @@ -678,6 +695,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]; @@ -824,12 +845,22 @@ - (void)render body = MPApplyCacheBusting(body, self.resourceTimestamps, baseURL); } + NSString *layoutJSON = nil; + if ([self.dataSource respondsToSelector:@selector(rendererTableLayoutsJSON:)]) + layoutJSON = [self.dataSource rendererTableLayoutsJSON:self]; + NSString *layoutTag = MPPreviewTableLayoutsTag(layoutJSON); + NSString *previewBody = body ?: @""; + NSString *title = [self.dataSource rendererHTMLTitle:self]; if (!self.checkboxBridgeToken.length) self.checkboxBridgeToken = NSUUID.UUID.UUIDString; - NSString *headTags = MPPreviewHeadTags(self.checkboxBridgeToken); + if (!self.tableLayoutBridgeToken.length) + self.tableLayoutBridgeToken = NSUUID.UUID.UUIDString; + NSString *headTags = MPPreviewHeadTags(self.checkboxBridgeToken, + self.tableLayoutBridgeToken); NSString *html = MPGetHTML( - title, headTags, body, self.stylesheets, MPAssetFullLink, + title, headTags, [layoutTag stringByAppendingString:previewBody], + self.stylesheets, MPAssetFullLink, self.scripts, MPAssetFullLink); [delegate renderer:self didProduceHTMLOutput:html]; diff --git a/MacDown/Resources/Extensions/export.css b/MacDown/Resources/Extensions/export.css index 42ce8ce1..0d0c2e46 100644 --- a/MacDown/Resources/Extensions/export.css +++ b/MacDown/Resources/Extensions/export.css @@ -23,12 +23,65 @@ li { overflow-wrap: break-word; } -/* Table cells - handle long content in tables */ -td, th { +/* Tables - preserve natural column widths and scroll when needed */ +table { + display: block; + width: max-content; + max-width: 100%; + overflow-x: auto; +} + +/* Table cells - handle long content in table bodies */ +td { word-break: break-word; 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; +} + +.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..5cd665f7 --- /dev/null +++ b/MacDown/Resources/Extensions/table-resize.js @@ -0,0 +1,192 @@ +/** + * Live-preview Markdown table column resizing. + * + * Widths are persisted by the native app through x-macdown-table-layout URLs. + * This script only runs in the editor preview; exports do not include it. + */ +(function () { + var MIN_WIDTH = 48; + var resizing = null; + + 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 currentLayouts() { + var node = document.getElementById('macdown-table-layouts'); + if (!node) { + return {}; + } + try { + return JSON.parse(node.textContent || '{}') || {}; + } catch (e) { + return {}; + } + } + + function bridgeToken() { + var tokenMeta = document.querySelector('meta[name="macdown-table-layout-token"]'); + return tokenMeta ? tokenMeta.getAttribute('content') : ''; + } + + 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 sendLayout(action, table, column, width) { + var token = bridgeToken(); + if (!token) { + return; + } + var url = 'x-macdown-table-layout://' + action + + '?token=' + encodeURIComponent(token) + + '&table=' + encodeURIComponent(table) + + '&column=' + encodeURIComponent(column); + if (width !== null && width !== undefined) { + url += '&width=' + encodeURIComponent(Math.round(width)); + } + window.location = url; + } + + 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, layouts) { + 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 = layouts[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 = ''; + sendLayout('reset', key, columnIndex, null); + }); + + 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)) { + sendLayout('set', resizing.table, resizing.column, width); + } + resizing = null; + document.documentElement.classList.remove('macdown-table-resizing'); + }); + + window.macdownInitTableResize = function () { + var layouts = currentLayouts(); + var tables = document.querySelectorAll('table'); + for (var i = 0; i < tables.length; i++) { + initTable(tables[i], i, layouts); + } + }; + + window.macdownInitTableResize(); +})(); diff --git a/MacDownTests/MPHTMLExportTests.m b/MacDownTests/MPHTMLExportTests.m index 7344b518..3429d73d 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 63a2909f..ced2d76b 100644 --- a/MacDownTests/MPRendererEdgeCaseTests.m +++ b/MacDownTests/MPRendererEdgeCaseTests.m @@ -154,8 +154,16 @@ - (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:@"name=\"macdown-table-layout-token\""], + @"Preview HTML should include a table layout bridge token"); + XCTAssertTrue([html containsString:@"table-resize.js"], + @"Preview HTML should include live table resizing behavior"); + XCTAssertTrue([html containsString:@"id=\"macdown-table-layouts\""], + @"Preview HTML should include table layout data for the live script"); XCTAssertTrue(self.renderer.checkboxBridgeToken.length > 0, @"Renderer should expose the active checkbox bridge token"); + XCTAssertTrue(self.renderer.tableLayoutBridgeToken.length > 0, + @"Renderer should expose the active table layout bridge token"); } - (void)testPreviewRenderKeepsCheckboxBridgeTokenStableAcrossRenders @@ -166,14 +174,35 @@ - (void)testPreviewRenderKeepsCheckboxBridgeTokenStableAcrossRenders [self.renderer parseMarkdown:self.dataSource.markdown]; [self.renderer render]; NSString *firstToken = [self.renderer.checkboxBridgeToken copy]; + NSString *firstTableToken = [self.renderer.tableLayoutBridgeToken copy]; [self.renderer render]; NSString *secondToken = [self.renderer.checkboxBridgeToken copy]; + NSString *secondTableToken = [self.renderer.tableLayoutBridgeToken copy]; XCTAssertEqualObjects(firstToken, secondToken, @"DOM-only preview refreshes keep the original head meta tags, so the checkbox bridge token must remain stable across renders"); + XCTAssertEqualObjects(firstTableToken, secondTableToken, + @"DOM-only preview refreshes keep the original head meta tags, so the table layout bridge token must remain stable across renders"); XCTAssertTrue([self.delegate.lastHTML containsString:firstToken], @"Rendered preview HTML should continue to expose the active checkbox bridge token"); + XCTAssertTrue([self.delegate.lastHTML containsString:firstTableToken], + @"Rendered preview HTML should continue to expose the active table layout bridge token"); +} + +- (void)testPreviewRenderEscapesTableLayoutJSONScriptClosers +{ + self.dataSource.markdown = @"| A |\n|---|\n| 1 |"; + self.dataSource.tableLayoutsJSON = @"{\"0:bad\":{\"\":120}}"; + + [self.renderer parseMarkdown:self.dataSource.markdown]; + [self.renderer render]; + + NSString *html = self.delegate.lastHTML; + XCTAssertTrue([html containsString:@"<\\/script>"], + @"Table layout JSON should escape script-closing text"); + XCTAssertFalse([html containsString:@"{\"0:bad\":{\"\":120}}"], + @"Table layout JSON should not expose a raw script closer"); } - (void)testHTMLExportDoesNotIncludePreviewOnlySecurityMetaTags @@ -189,6 +218,12 @@ - (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:@"macdown-table-layout-token"], + @"Preview-only table layout tokens should not leak into exports"); + XCTAssertFalse([html containsString:@"table-resize.js"], + @"Preview-only table resizing script should not leak into exports"); + XCTAssertFalse([html containsString:@"macdown-table-layouts"], + @"Preview-only table layout JSON should not leak into exports"); } diff --git a/MacDownTests/MPRendererTestHelpers.h b/MacDownTests/MPRendererTestHelpers.h index 862ce4c6..0b72fb83 100644 --- a/MacDownTests/MPRendererTestHelpers.h +++ b/MacDownTests/MPRendererTestHelpers.h @@ -21,6 +21,7 @@ @interface MPMockRendererDataSource : NSObject @property (nonatomic, copy) NSString *markdown; @property (nonatomic, copy) NSString *title; +@property (nonatomic, copy) NSString *tableLayoutsJSON; @end diff --git a/MacDownTests/MPRendererTestHelpers.m b/MacDownTests/MPRendererTestHelpers.m index a02c45e6..085da59a 100644 --- a/MacDownTests/MPRendererTestHelpers.m +++ b/MacDownTests/MPRendererTestHelpers.m @@ -16,6 +16,7 @@ - (instancetype)init if (self) { self.markdown = @""; self.title = @""; + self.tableLayoutsJSON = @"{}"; } return self; } @@ -35,6 +36,11 @@ - (NSString *)rendererHTMLTitle:(MPRenderer *)renderer return self.title; } +- (NSString *)rendererTableLayoutsJSON:(MPRenderer *)renderer +{ + return self.tableLayoutsJSON; +} + @end diff --git a/MacDownTests/MPScrollSyncTests.m b/MacDownTests/MPScrollSyncTests.m index b925232a..b6e22ba4 100644 --- a/MacDownTests/MPScrollSyncTests.m +++ b/MacDownTests/MPScrollSyncTests.m @@ -28,6 +28,8 @@ @interface MPDocument (ScrollSyncTesting) @property (strong) MPRenderer *renderer; @property (unsafe_unretained) MPEditorView *editor; @property (nonatomic) NSUInteger scrollOwner; // Issue #342: MPScrollOwner enum +@property (strong) NSMutableDictionary *tableLayouts; +@property (strong) NSURL *tableLayoutsFileURL; - (void)updateHeaderLocations; - (void)syncScrollers; - (void)syncScrollersReverse; @@ -43,6 +45,8 @@ - (void)reloadFromLoadedString; @property (nonatomic, readonly) BOOL isPreviewReady; // Commit 5 (gap 10): checkbox toggle - (void)handleCheckboxToggle:(NSURL *)url; +- (void)handleTableLayoutURL:(NSURL *)url; +- (NSString *)rendererTableLayoutsJSON:(MPRenderer *)renderer; // Commit 6 (gaps 1+3): layout-change sync - (void)refreshHeaderCacheAfterResize; - (void)windowDidEndLiveResize:(NSNotification *)notification; @@ -1943,6 +1947,164 @@ - (void)testHandleCheckboxToggleAcceptsMatchingToken @"Checkbox toggles with the active bridge token should update the source document"); } +- (void)testHandleTableLayoutRejectsMismatchedToken +{ + MPDocument *doc = [[MPDocument alloc] init]; + MPRenderer *renderer = [[MPRenderer alloc] init]; + doc.renderer = renderer; + [renderer setValue:@"expected-token" forKey:@"tableLayoutBridgeToken"]; + + NSURL *url = [NSURL URLWithString: + @"x-macdown-table-layout://set?token=wrong-token&table=0:abc&column=1&width=120"]; + [doc handleTableLayoutURL:url]; + + XCTAssertEqual(doc.tableLayouts.count, 0u, + @"Table layout messages with a mismatched token must be ignored"); +} + +- (void)testHandleTableLayoutDoesNotPersistUnsavedDocuments +{ + MPDocument *doc = [[MPDocument alloc] init]; + MPRenderer *renderer = [[MPRenderer alloc] init]; + doc.renderer = renderer; + [renderer setValue:@"expected-token" forKey:@"tableLayoutBridgeToken"]; + + NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"macdown-table-layout-%@.json", + NSUUID.UUID.UUIDString]]; + doc.tableLayoutsFileURL = [NSURL fileURLWithPath:path]; + + NSURL *url = [NSURL URLWithString: + @"x-macdown-table-layout://set?token=expected-token&table=0:abc&column=1&width=120"]; + [doc handleTableLayoutURL:url]; + + XCTAssertEqualObjects(doc.tableLayouts[@"0:abc"][@"1"], @120, + @"Unsaved documents should keep table widths for the current session"); + XCTAssertFalse([[NSFileManager defaultManager] fileExistsAtPath:path], + @"Unsaved documents must not write table widths to Application Support"); +} + +- (void)testUntitledTableLayoutsPersistAfterSavingDocument +{ + NSString *layoutPath = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"macdown-table-layout-%@.json", + NSUUID.UUID.UUIDString]]; + NSURL *layoutURL = [NSURL fileURLWithPath:layoutPath]; + NSURL *documentURL = [NSURL fileURLWithPath: + [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"saved-table-doc-%@.md", + NSUUID.UUID.UUIDString]]]; + + MPDocument *doc = [[MPDocument alloc] init]; + MPRenderer *renderer = [[MPRenderer alloc] init]; + doc.renderer = renderer; + doc.tableLayoutsFileURL = layoutURL; + [renderer setValue:@"expected-token" forKey:@"tableLayoutBridgeToken"]; + + NSURL *setURL = [NSURL URLWithString: + @"x-macdown-table-layout://set?token=expected-token&table=0:abc&column=1&width=120"]; + [doc handleTableLayoutURL:setURL]; + XCTAssertFalse([[NSFileManager defaultManager] fileExistsAtPath:layoutPath], + @"Untitled table widths should remain session-only before save"); + + [doc setFileURL:documentURL]; + NSString *json = [doc rendererTableLayoutsJSON:renderer]; + + XCTAssertEqualObjects(doc.tableLayouts[@"0:abc"][@"1"], @120, + @"Saving an untitled document should keep session table widths"); + XCTAssertTrue([json containsString:@"0:abc"], + @"Migrated table layout JSON should include the existing table key"); + XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath:layoutPath], + @"Migrated table widths should persist under the saved document path"); +} + +- (void)testFileBackedTableLayoutsPersistAfterSaveAs +{ + NSString *layoutPath = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"macdown-table-layout-%@.json", + NSUUID.UUID.UUIDString]]; + NSURL *layoutURL = [NSURL fileURLWithPath:layoutPath]; + NSURL *originalURL = [NSURL fileURLWithPath: + [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"original-table-doc-%@.md", + NSUUID.UUID.UUIDString]]]; + NSURL *saveAsURL = [NSURL fileURLWithPath: + [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"save-as-table-doc-%@.md", + NSUUID.UUID.UUIDString]]]; + + MPDocument *doc = [[MPDocument alloc] init]; + MPRenderer *renderer = [[MPRenderer alloc] init]; + doc.renderer = renderer; + doc.tableLayoutsFileURL = layoutURL; + [doc setFileURL:originalURL]; + [renderer setValue:@"expected-token" forKey:@"tableLayoutBridgeToken"]; + + NSURL *setURL = [NSURL URLWithString: + @"x-macdown-table-layout://set?token=expected-token&table=0:abc&column=1&width=144"]; + [doc handleTableLayoutURL:setURL]; + + [doc setFileURL:saveAsURL]; + NSString *json = [doc rendererTableLayoutsJSON:renderer]; + + XCTAssertEqualObjects(doc.tableLayouts[@"0:abc"][@"1"], @144, + @"Save As should keep current table widths in memory"); + XCTAssertTrue([json containsString:@"144"], + @"Save As should expose current table widths in preview JSON"); + + MPDocument *reopened = [[MPDocument alloc] init]; + reopened.tableLayoutsFileURL = layoutURL; + [reopened setFileURL:saveAsURL]; + NSString *savedJSON = [reopened rendererTableLayoutsJSON:renderer]; + XCTAssertTrue([savedJSON containsString:@"144"], + @"Save As should persist current table widths under the new document path"); +} + +- (void)testHandleTableLayoutSavesLoadsAndResetsFileBackedWidths +{ + NSString *layoutPath = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"macdown-table-layout-%@.json", + NSUUID.UUID.UUIDString]]; + NSURL *layoutURL = [NSURL fileURLWithPath:layoutPath]; + NSURL *documentURL = [NSURL fileURLWithPath: + [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"table-doc-%@.md", NSUUID.UUID.UUIDString]]]; + + MPDocument *doc = [[MPDocument alloc] init]; + MPRenderer *renderer = [[MPRenderer alloc] init]; + doc.renderer = renderer; + doc.tableLayoutsFileURL = layoutURL; + [doc setFileURL:documentURL]; + [renderer setValue:@"expected-token" forKey:@"tableLayoutBridgeToken"]; + + NSURL *setURL = [NSURL URLWithString: + @"x-macdown-table-layout://set?token=expected-token&table=0:abc&column=1&width=132"]; + [doc handleTableLayoutURL:setURL]; + + XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath:layoutPath], + @"File-backed documents should persist table widths"); + + MPDocument *reopened = [[MPDocument alloc] init]; + reopened.tableLayoutsFileURL = layoutURL; + [reopened setFileURL:documentURL]; + NSString *json = [reopened rendererTableLayoutsJSON:renderer]; + XCTAssertTrue([json containsString:@"0:abc"], + @"Saved table layouts should load for the same document URL"); + XCTAssertTrue([json containsString:@"132"], + @"Saved column width should load for the same document URL"); + + NSURL *resetURL = [NSURL URLWithString: + @"x-macdown-table-layout://reset?token=expected-token&table=0:abc&column=1"]; + [doc handleTableLayoutURL:resetURL]; + + MPDocument *afterReset = [[MPDocument alloc] init]; + afterReset.tableLayoutsFileURL = layoutURL; + [afterReset setFileURL:documentURL]; + NSString *resetJSON = [afterReset rendererTableLayoutsJSON:renderer]; + XCTAssertFalse([resetJSON containsString:@"0:abc"], + @"Reset should remove saved table layout widths"); +} + #pragma mark - Group J — Sync after layout changes (Commit 6, gaps 1+3) /** diff --git a/Podfile.lock b/Podfile.lock index 7d7f0c57..49e7b5a8 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -42,6 +42,6 @@ SPEC CHECKSUMS: MASPreferences: 1ba2deb14086792857af44d22846fc4aae477fd9 PAPreferences: 9f0ffb1e67174a0df001af9d3320166ceb9ee6f5 -PODFILE CHECKSUM: 9feb752b3e32e9399d9f753e2da9ba974d1f877d +PODFILE CHECKSUM: 1a1bfe9cf20bb15ea22bf84145a2dc61388c7178 COCOAPODS: 1.16.2