diff --git a/main.js b/main.js index f0c0639..91d83d6 100644 --- a/main.js +++ b/main.js @@ -20122,6 +20122,15 @@ var EN_MESSAGES = { "colorScheme.preview.swatch": "Color preview", "colorScheme.preview.targets": "Targets", "colorScheme.preview.empty": "No colors defined.", + // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module + "colorScheme.preview.blankColor": "(blank)", + // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module + "colorScheme.preview.unsupportedColor": "Unsupported color value; picking a color will replace it with #RRGGBB.", + "colorScheme.preview.pick": "Pick", + "colorScheme.preview.pickAria": "Pick {column} color", + "colorScheme.preview.pickApplied": "Color updated.", + "colorScheme.preview.pickFailed": "Could not update the color cell.", + "colorScheme.preview.pickUnchanged": "Color is already set.", "colorScheme.field.target": "Target", "colorScheme.field.kind": "Kind", "colorScheme.field.fill": "Fill", @@ -20514,6 +20523,13 @@ var JA_MESSAGES = { "colorScheme.preview.swatch": "\u30AB\u30E9\u30FC\u78BA\u8A8D", "colorScheme.preview.targets": "\u5BFE\u8C61", "colorScheme.preview.empty": "Color \u306F\u5B9A\u7FA9\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002", + "colorScheme.preview.blankColor": "\uFF08\u7A7A\uFF09", + "colorScheme.preview.unsupportedColor": "\u672A\u5BFE\u5FDC\u306E\u8272\u5024\u3067\u3059\u3002\u8272\u3092\u9078\u3076\u3068 #RRGGBB \u3067\u7F6E\u304D\u63DB\u3048\u307E\u3059\u3002", + "colorScheme.preview.pick": "\u9078\u629E", + "colorScheme.preview.pickAria": "{column} \u306E\u8272\u3092\u9078\u629E", + "colorScheme.preview.pickApplied": "\u8272\u3092\u66F4\u65B0\u3057\u307E\u3057\u305F\u3002", + "colorScheme.preview.pickFailed": "\u8272\u30BB\u30EB\u3092\u66F4\u65B0\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\u3002", + "colorScheme.preview.pickUnchanged": "\u8272\u306F\u3059\u3067\u306B\u8A2D\u5B9A\u3055\u308C\u3066\u3044\u307E\u3059\u3002", "colorScheme.field.target": "\u5BFE\u8C61", "colorScheme.field.kind": "kind", "colorScheme.field.fill": "\u5857\u308A", @@ -21260,6 +21276,132 @@ function escapeDomainMindmapLabel(value) { return value.replace(/\r\n?/g, "\n").replace(/\n/g, " ").replace(/\(/g, "\uFF08").replace(/\)/g, "\uFF09").replace(/\s+/g, " ").trim(); } +// src/core/color-scheme-table-editor.ts +var COLORS_SECTION_NAME = "Colors"; +var COLOR_HEADERS2 = ["target", "kind", "fill", "stroke", "text", "notes"]; +var HEX_RGB_PATTERN = /^#([0-9a-fA-F]{3})$/; +var HEX_RRGGBB_PATTERN = /^#[0-9a-fA-F]{6}$/; +function updateColorSchemeColorCell(markdown, request) { + const normalizedColor = normalizeHexColorForPicker(request.value); + if (!normalizedColor || !HEX_RRGGBB_PATTERN.test(normalizedColor)) { + return unchanged2(markdown, "invalid-color"); + } + const columnIndex = COLOR_HEADERS2.indexOf(request.columnName); + if (columnIndex < 0) { + return unchanged2(markdown, "column-missing"); + } + const lineEnding = markdown.includes("\r\n") ? "\r\n" : "\n"; + const lines = markdown.split(/\r?\n/); + const sectionRange = findMarkdownSectionRange(lines, COLORS_SECTION_NAME); + if (!sectionRange) { + return unchanged2(markdown, "section-missing"); + } + const tableStart = findTableStart(lines, sectionRange.start + 1, sectionRange.end); + if (tableStart === null || tableStart + 1 >= sectionRange.end) { + return unchanged2(markdown, "table-missing"); + } + const headers = splitMarkdownTableRow(lines[tableStart]) ?? []; + if (!sameHeaders7(headers, [...COLOR_HEADERS2])) { + return unchanged2(markdown, "header-mismatch"); + } + const separatorCells = splitMarkdownTableRow(lines[tableStart + 1]); + if (!separatorCells || separatorCells.length !== COLOR_HEADERS2.length) { + return unchanged2(markdown, "table-missing"); + } + const targetLineIndex = findDataRowLineIndex(lines, tableStart + 2, sectionRange.end, request.rowIndex); + if (targetLineIndex === null) { + return unchanged2(markdown, "row-missing"); + } + const ranges = getMarkdownTableCellRanges(lines[targetLineIndex]); + if (!ranges || !ranges[columnIndex]) { + return unchanged2(markdown, "column-missing"); + } + const range = ranges[columnIndex]; + const line = lines[targetLineIndex]; + const rawCell = line.slice(range.rawStart, range.rawEnd); + const updatedLine = rawCell.trim().length === 0 ? `${line.slice(0, range.rawStart)} ${normalizedColor} ${line.slice(range.rawEnd)}` : `${line.slice(0, range.contentStart)}${normalizedColor}${line.slice(range.contentEnd)}`; + if (updatedLine === line) { + return { + changed: false, + updatedMarkdown: markdown, + status: "unchanged" + }; + } + const updatedLines = [...lines]; + updatedLines[targetLineIndex] = updatedLine; + return { + changed: true, + updatedMarkdown: updatedLines.join(lineEnding), + status: "updated" + }; +} +function normalizeHexColorForPicker(value) { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const rgb = trimmed.match(HEX_RGB_PATTERN); + if (rgb) { + return `#${rgb[1].split("").map((char) => `${char}${char}`).join("")}`.toLowerCase(); + } + return HEX_RRGGBB_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; +} +function findMarkdownSectionRange(lines, sectionName) { + const headingPattern = /^##\s+(.+?)\s*$/; + let start = null; + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(headingPattern); + if (!match) { + continue; + } + if (start !== null) { + return { start, end: index }; + } + if (normalizeHeadingName(match[1]) === normalizeHeadingName(sectionName)) { + start = index; + } + } + return start === null ? null : { start, end: lines.length }; +} +function findTableStart(lines, start, end) { + for (let index = start; index < end; index += 1) { + if (lines[index].trim().startsWith("|")) { + return index; + } + } + return null; +} +function findDataRowLineIndex(lines, start, end, targetRowIndex) { + let currentDataRowIndex = 0; + for (let index = start; index < end; index += 1) { + if (!lines[index].trim().startsWith("|")) { + continue; + } + const values = splitMarkdownTableRow(lines[index]); + if (!values || isEmptyMarkdownTableDataRow(values)) { + continue; + } + if (currentDataRowIndex === targetRowIndex) { + return index; + } + currentDataRowIndex += 1; + } + return null; +} +function sameHeaders7(actual, expected) { + return actual.length === expected.length && actual.every((header, index) => header === expected[index]); +} +function normalizeHeadingName(value) { + return value.trim().replace(/\s+#+$/, "").trim().toLowerCase(); +} +function unchanged2(markdown, status) { + return { + changed: false, + updatedMarkdown: markdown, + status + }; +} + // src/views/usage-view-renderer.ts function renderUsageViewSections(container, sections, options) { for (const section of sections) { @@ -22834,9 +22976,12 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { this.t("colorScheme.preview.colors"), true ); - this.renderColorSchemeTable(section, state.model.colors); + const canPickColors = !state.warnings.some( + (warning) => warning.code === "invalid-table-column" && (warning.field === "Colors" || warning.context?.section === "Colors" || /section "Colors"/.test(warning.message)) + ); + this.renderColorSchemeTable(section, state.model.colors, canPickColors); } - renderColorSchemeTable(container, colors) { + renderColorSchemeTable(container, colors, canPickColors) { const tableWrap = container.createDiv({ cls: "model-weave-table-wrap" }); const table = tableWrap.createEl("table", { cls: "model-weave-summary-table model-weave-data-table" @@ -22867,7 +23012,7 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { return; } for (const color of colors) { - this.renderColorSchemeTableRow(tbody, color); + this.renderColorSchemeTableRow(tbody, color, canPickColors); } } renderAppliedColorScheme(container, colorScheme, targets) { @@ -22892,17 +23037,13 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { this.t ); } - renderColorSchemeTableRow(tbody, color) { + renderColorSchemeTableRow(tbody, color, canPickColors) { const row = tbody.createEl("tr"); - for (const value of [ - color.target ?? this.t("domains.value.none"), - color.kind, - color.fill ?? this.t("domains.value.none"), - color.stroke ?? this.t("domains.value.none"), - color.text ?? this.t("domains.value.none") - ]) { - row.createEl("td", { text: value }); - } + row.createEl("td", { text: color.target ?? this.t("domains.value.none") }); + row.createEl("td", { text: color.kind }); + this.renderColorSchemeColorCell(row, color, "fill", canPickColors); + this.renderColorSchemeColorCell(row, color, "stroke", canPickColors); + this.renderColorSchemeColorCell(row, color, "text", canPickColors); const swatchCell = row.createEl("td"); const swatch = swatchCell.createSpan({ cls: "model-weave-color-swatch" @@ -22919,6 +23060,77 @@ var ModelingPreviewView = class extends import_obsidian7.ItemView { swatch.textContent = "Aa"; row.createEl("td", { text: color.notes ?? "" }); } + renderColorSchemeColorCell(row, color, columnName, canPickColors) { + const value = color[columnName] ?? ""; + const normalized = normalizeHexColorForPicker(value); + const cell = row.createEl("td"); + const control = cell.createDiv({ cls: "model-weave-color-cell-control" }); + const swatch = control.createSpan({ + cls: normalized ? "model-weave-color-cell-swatch" : "model-weave-color-cell-swatch model-weave-color-cell-swatch-empty", + attr: { "aria-hidden": "true" } + }); + if (normalized) { + swatch.style.backgroundColor = normalized; + } + const text = control.createSpan({ + text: value || this.t("colorScheme.preview.blankColor"), + cls: value ? "model-weave-color-cell-value" : "model-weave-color-cell-value model-weave-summary-empty-cell" + }); + if (normalized) { + text.title = normalized; + } else if (value) { + text.title = this.t("colorScheme.preview.unsupportedColor"); + } + if (!canPickColors) { + return; + } + const input = control.createEl("input", { + cls: "model-weave-color-picker-input", + attr: { + type: "color", + value: normalized ?? "#ffffff", + "aria-label": this.t("colorScheme.preview.pickAria", { + column: this.t(`colorScheme.field.${columnName}`) + }) + } + }); + const button = control.createEl("button", { + text: this.t("colorScheme.preview.pick"), + cls: "model-weave-color-picker-button", + attr: { type: "button" } + }); + button.addEventListener("click", () => { + input.click(); + }); + input.addEventListener("change", () => { + void this.applyColorSchemeColorPick(color.rowIndex, columnName, input.value); + }); + } + async applyColorSchemeColorPick(rowIndex, columnName, value) { + try { + const file = this.getCurrentTFileForQuickFix(); + if (!file) { + new import_obsidian7.Notice(this.t("colorScheme.preview.pickFailed")); + return; + } + const markdown = await this.app.vault.read(file); + const result = updateColorSchemeColorCell(markdown, { + rowIndex, + columnName, + value + }); + if (!result.changed) { + const noticeKey = result.status === "unchanged" ? "colorScheme.preview.pickUnchanged" : "colorScheme.preview.pickFailed"; + new import_obsidian7.Notice(this.t(noticeKey)); + return; + } + await this.app.vault.modify(file, result.updatedMarkdown); + new import_obsidian7.Notice(this.t("colorScheme.preview.pickApplied")); + this.renderCurrentState(); + } catch { + new import_obsidian7.Notice(this.t("colorScheme.preview.pickFailed")); + } + } renderDomainTable(container, domains) { const section = this.createCollapsibleSection( container, diff --git a/src/core/color-scheme-table-editor.ts b/src/core/color-scheme-table-editor.ts new file mode 100644 index 0000000..a11d996 --- /dev/null +++ b/src/core/color-scheme-table-editor.ts @@ -0,0 +1,188 @@ +import { + getMarkdownTableCellRanges, + isEmptyMarkdownTableDataRow, + splitMarkdownTableRow +} from "../parsers/markdown-table"; + +const COLORS_SECTION_NAME = "Colors"; +const COLOR_HEADERS = ["target", "kind", "fill", "stroke", "text", "notes"] as const; +const HEX_RGB_PATTERN = /^#([0-9a-fA-F]{3})$/; +const HEX_RRGGBB_PATTERN = /^#[0-9a-fA-F]{6}$/; + +export type ColorSchemeEditableColumn = "fill" | "stroke" | "text"; + +export interface UpdateColorSchemeCellRequest { + rowIndex: number; + columnName: ColorSchemeEditableColumn; + value: string; +} + +export interface UpdateColorSchemeCellResult { + changed: boolean; + updatedMarkdown: string; + status: "updated" | "unchanged" | "invalid-color" | "section-missing" | "table-missing" | "header-mismatch" | "row-missing" | "column-missing"; +} + +export function updateColorSchemeColorCell( + markdown: string, + request: UpdateColorSchemeCellRequest +): UpdateColorSchemeCellResult { + const normalizedColor = normalizeHexColorForPicker(request.value); + if (!normalizedColor || !HEX_RRGGBB_PATTERN.test(normalizedColor)) { + return unchanged(markdown, "invalid-color"); + } + + const columnIndex = COLOR_HEADERS.indexOf(request.columnName); + if (columnIndex < 0) { + return unchanged(markdown, "column-missing"); + } + + const lineEnding = markdown.includes("\r\n") ? "\r\n" : "\n"; + const lines = markdown.split(/\r?\n/); + const sectionRange = findMarkdownSectionRange(lines, COLORS_SECTION_NAME); + if (!sectionRange) { + return unchanged(markdown, "section-missing"); + } + + const tableStart = findTableStart(lines, sectionRange.start + 1, sectionRange.end); + if (tableStart === null || tableStart + 1 >= sectionRange.end) { + return unchanged(markdown, "table-missing"); + } + + const headers = splitMarkdownTableRow(lines[tableStart]) ?? []; + if (!sameHeaders(headers, [...COLOR_HEADERS])) { + return unchanged(markdown, "header-mismatch"); + } + + const separatorCells = splitMarkdownTableRow(lines[tableStart + 1]); + if (!separatorCells || separatorCells.length !== COLOR_HEADERS.length) { + return unchanged(markdown, "table-missing"); + } + + const targetLineIndex = findDataRowLineIndex(lines, tableStart + 2, sectionRange.end, request.rowIndex); + if (targetLineIndex === null) { + return unchanged(markdown, "row-missing"); + } + + const ranges = getMarkdownTableCellRanges(lines[targetLineIndex]); + if (!ranges || !ranges[columnIndex]) { + return unchanged(markdown, "column-missing"); + } + + const range = ranges[columnIndex]; + const line = lines[targetLineIndex]; + const rawCell = line.slice(range.rawStart, range.rawEnd); + const updatedLine = rawCell.trim().length === 0 + ? `${line.slice(0, range.rawStart)} ${normalizedColor} ${line.slice(range.rawEnd)}` + : `${line.slice(0, range.contentStart)}${normalizedColor}${line.slice(range.contentEnd)}`; + if (updatedLine === line) { + return { + changed: false, + updatedMarkdown: markdown, + status: "unchanged" + }; + } + + const updatedLines = [...lines]; + updatedLines[targetLineIndex] = updatedLine; + return { + changed: true, + updatedMarkdown: updatedLines.join(lineEnding), + status: "updated" + }; +} + +export function normalizeHexColorForPicker(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + const rgb = trimmed.match(HEX_RGB_PATTERN); + if (rgb) { + return `#${rgb[1].split("").map((char) => `${char}${char}`).join("")}`.toLowerCase(); + } + + return HEX_RRGGBB_PATTERN.test(trimmed) + ? trimmed.toLowerCase() + : null; +} + +function findMarkdownSectionRange( + lines: string[], + sectionName: string +): { start: number; end: number } | null { + const headingPattern = /^##\s+(.+?)\s*$/; + let start: number | null = null; + + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(headingPattern); + if (!match) { + continue; + } + + if (start !== null) { + return { start, end: index }; + } + + if (normalizeHeadingName(match[1]) === normalizeHeadingName(sectionName)) { + start = index; + } + } + + return start === null ? null : { start, end: lines.length }; +} + +function findTableStart(lines: string[], start: number, end: number): number | null { + for (let index = start; index < end; index += 1) { + if (lines[index].trim().startsWith("|")) { + return index; + } + } + return null; +} + +function findDataRowLineIndex( + lines: string[], + start: number, + end: number, + targetRowIndex: number +): number | null { + let currentDataRowIndex = 0; + for (let index = start; index < end; index += 1) { + if (!lines[index].trim().startsWith("|")) { + continue; + } + + const values = splitMarkdownTableRow(lines[index]); + if (!values || isEmptyMarkdownTableDataRow(values)) { + continue; + } + + if (currentDataRowIndex === targetRowIndex) { + return index; + } + currentDataRowIndex += 1; + } + return null; +} + +function sameHeaders(actual: string[], expected: string[]): boolean { + return actual.length === expected.length && + actual.every((header, index) => header === expected[index]); +} + +function normalizeHeadingName(value: string): string { + return value.trim().replace(/\s+#+$/, "").trim().toLowerCase(); +} + +function unchanged( + markdown: string, + status: Exclude +): UpdateColorSchemeCellResult { + return { + changed: false, + updatedMarkdown: markdown, + status + }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 7b8ffd6..3fb0cc0 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -368,6 +368,15 @@ export const EN_MESSAGES: ModelWeaveMessageDictionary = { "colorScheme.preview.swatch": "Color preview", "colorScheme.preview.targets": "Targets", "colorScheme.preview.empty": "No colors defined.", + // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module + "colorScheme.preview.blankColor": "(blank)", + // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module + "colorScheme.preview.unsupportedColor": "Unsupported color value; picking a color will replace it with #RRGGBB.", + "colorScheme.preview.pick": "Pick", + "colorScheme.preview.pickAria": "Pick {column} color", + "colorScheme.preview.pickApplied": "Color updated.", + "colorScheme.preview.pickFailed": "Could not update the color cell.", + "colorScheme.preview.pickUnchanged": "Color is already set.", "colorScheme.field.target": "Target", "colorScheme.field.kind": "Kind", "colorScheme.field.fill": "Fill", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 7be85c4..d6f4ef3 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -325,6 +325,13 @@ export const JA_MESSAGES: ModelWeaveMessageDictionary = { "colorScheme.preview.swatch": "カラー確認", "colorScheme.preview.targets": "対象", "colorScheme.preview.empty": "Color は定義されていません。", + "colorScheme.preview.blankColor": "(空)", + "colorScheme.preview.unsupportedColor": "未対応の色値です。色を選ぶと #RRGGBB で置き換えます。", + "colorScheme.preview.pick": "選択", + "colorScheme.preview.pickAria": "{column} の色を選択", + "colorScheme.preview.pickApplied": "色を更新しました。", + "colorScheme.preview.pickFailed": "色セルを更新できませんでした。", + "colorScheme.preview.pickUnchanged": "色はすでに設定されています。", "colorScheme.field.target": "対象", "colorScheme.field.kind": "kind", "colorScheme.field.fill": "塗り", diff --git a/src/views/modeling-preview-view.ts b/src/views/modeling-preview-view.ts index 5449750..9db1f35 100644 --- a/src/views/modeling-preview-view.ts +++ b/src/views/modeling-preview-view.ts @@ -69,6 +69,11 @@ import { getExpectedHeaderForDiagnostic as getSchemaExpectedHeaderForDiagnostic, resolveDiagnosticSectionGuidance } from "../core/diagnostic-section-guidance"; +import { + normalizeHexColorForPicker, + updateColorSchemeColorCell, + type ColorSchemeEditableColumn +} from "../core/color-scheme-table-editor"; import type { ModelWeaveViewerPreferences } from "../settings/model-weave-settings"; import type { ModelingVaultIndex } from "../core/vault-index"; import type { @@ -1974,12 +1979,21 @@ export class ModelingPreviewView extends ItemView { this.t("colorScheme.preview.colors"), true ); - this.renderColorSchemeTable(section, state.model.colors); + const canPickColors = !state.warnings.some((warning) => + warning.code === "invalid-table-column" && + ( + warning.field === "Colors" || + warning.context?.section === "Colors" || + /section "Colors"/.test(warning.message) + ) + ); + this.renderColorSchemeTable(section, state.model.colors, canPickColors); } private renderColorSchemeTable( container: HTMLElement, - colors: ColorSchemeEntry[] + colors: ColorSchemeEntry[], + canPickColors: boolean ): void { const tableWrap = container.createDiv({ cls: "model-weave-table-wrap" }); const table = tableWrap.createEl("table", { @@ -2013,7 +2027,7 @@ export class ModelingPreviewView extends ItemView { } for (const color of colors) { - this.renderColorSchemeTableRow(tbody, color); + this.renderColorSchemeTableRow(tbody, color, canPickColors); } } @@ -2051,18 +2065,15 @@ export class ModelingPreviewView extends ItemView { private renderColorSchemeTableRow( tbody: HTMLElement, - color: ColorSchemeEntry + color: ColorSchemeEntry, + canPickColors: boolean ): void { const row = tbody.createEl("tr"); - for (const value of [ - color.target ?? this.t("domains.value.none"), - color.kind, - color.fill ?? this.t("domains.value.none"), - color.stroke ?? this.t("domains.value.none"), - color.text ?? this.t("domains.value.none") - ]) { - row.createEl("td", { text: value }); - } + row.createEl("td", { text: color.target ?? this.t("domains.value.none") }); + row.createEl("td", { text: color.kind }); + this.renderColorSchemeColorCell(row, color, "fill", canPickColors); + this.renderColorSchemeColorCell(row, color, "stroke", canPickColors); + this.renderColorSchemeColorCell(row, color, "text", canPickColors); const swatchCell = row.createEl("td"); const swatch = swatchCell.createSpan({ @@ -2082,6 +2093,98 @@ export class ModelingPreviewView extends ItemView { row.createEl("td", { text: color.notes ?? "" }); } + private renderColorSchemeColorCell( + row: HTMLElement, + color: ColorSchemeEntry, + columnName: ColorSchemeEditableColumn, + canPickColors: boolean + ): void { + const value = color[columnName] ?? ""; + const normalized = normalizeHexColorForPicker(value); + const cell = row.createEl("td"); + const control = cell.createDiv({ cls: "model-weave-color-cell-control" }); + const swatch = control.createSpan({ + cls: normalized + ? "model-weave-color-cell-swatch" + : "model-weave-color-cell-swatch model-weave-color-cell-swatch-empty", + attr: { "aria-hidden": "true" } + }); + if (normalized) { + swatch.style.backgroundColor = normalized; + } + const text = control.createSpan({ + text: value || this.t("colorScheme.preview.blankColor"), + cls: value + ? "model-weave-color-cell-value" + : "model-weave-color-cell-value model-weave-summary-empty-cell" + }); + if (normalized) { + text.title = normalized; + } else if (value) { + text.title = this.t("colorScheme.preview.unsupportedColor"); + } + + if (!canPickColors) { + return; + } + + const input = control.createEl("input", { + cls: "model-weave-color-picker-input", + attr: { + type: "color", + value: normalized ?? "#ffffff", + "aria-label": this.t("colorScheme.preview.pickAria", { + column: this.t(`colorScheme.field.${columnName}`) + }) + } + }); + const button = control.createEl("button", { + text: this.t("colorScheme.preview.pick"), + cls: "model-weave-color-picker-button", + attr: { type: "button" } + }); + button.addEventListener("click", () => { + input.click(); + }); + input.addEventListener("change", () => { + void this.applyColorSchemeColorPick(color.rowIndex, columnName, input.value); + }); + } + + private async applyColorSchemeColorPick( + rowIndex: number, + columnName: ColorSchemeEditableColumn, + value: string + ): Promise { + try { + const file = this.getCurrentTFileForQuickFix(); + if (!file) { + new Notice(this.t("colorScheme.preview.pickFailed")); + return; + } + + const markdown = await this.app.vault.read(file); + const result = updateColorSchemeColorCell(markdown, { + rowIndex, + columnName, + value + }); + if (!result.changed) { + const noticeKey = result.status === "unchanged" + ? "colorScheme.preview.pickUnchanged" + : "colorScheme.preview.pickFailed"; + new Notice(this.t(noticeKey)); + return; + } + + await this.app.vault.modify(file, result.updatedMarkdown); + new Notice(this.t("colorScheme.preview.pickApplied")); + this.renderCurrentState(); + } catch { + new Notice(this.t("colorScheme.preview.pickFailed")); + } + } + private renderDomainTable(container: HTMLElement, domains: DomainEntry[]): void { const section = this.createCollapsibleSection( container, diff --git a/styles.css b/styles.css index e04a667..4f6e234 100644 --- a/styles.css +++ b/styles.css @@ -931,6 +931,53 @@ body.model-weave-focus-mode-active .popover.hover-popover { font-size: 0.75rem; } +.model-weave-color-cell-control { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + min-width: 9rem; +} + +.model-weave-color-cell-swatch { + width: 1.25rem; + height: 1.25rem; + flex: 0 0 auto; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary-alt); +} + +.model-weave-color-cell-swatch-empty { + background: repeating-linear-gradient( + 45deg, + var(--background-primary-alt), + var(--background-primary-alt) 4px, + var(--background-modifier-border) 4px, + var(--background-modifier-border) 5px + ); +} + +.model-weave-color-cell-value { + font-family: var(--font-monospace); + font-size: 0.85em; + white-space: nowrap; +} + +.model-weave-color-picker-input { + width: 1px; + height: 1px; + opacity: 0; + position: absolute; + pointer-events: none; +} + +.model-weave-color-picker-button { + padding: 2px 8px; + font-size: 0.8em; + line-height: 1.4; +} + .model-weave-summary-details .model-weave-summary-empty-cell { color: var(--text-muted); font-style: italic; diff --git a/test/color-scheme-picker-assist.test.mjs b/test/color-scheme-picker-assist.test.mjs new file mode 100644 index 0000000..add6dd4 --- /dev/null +++ b/test/color-scheme-picker-assist.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const outputFile = "dist/test-color-scheme-picker-assist.mjs"; + +await build({ + stdin: { + contents: [ + 'export { parseColorSchemeFile } from "./src/parsers/color-scheme-parser";', + 'export { buildCurrentObjectDiagnostics } from "./src/core/current-file-diagnostics";', + 'export { getExpectedHeaderForDiagnostic } from "./src/core/diagnostic-section-guidance";', + 'export { normalizeHexColorForPicker, updateColorSchemeColorCell } from "./src/core/color-scheme-table-editor";' + ].join("\n"), + resolveDir: ".", + sourcefile: "test-color-scheme-picker-assist-entry.ts", + loader: "ts" + }, + bundle: true, + format: "esm", + platform: "browser", + outfile: outputFile, + plugins: [ + { + name: "stub-obsidian", + setup(buildApi) { + buildApi.onResolve({ filter: /^obsidian$/ }, () => ({ + path: "obsidian", + namespace: "stub" + })); + buildApi.onLoad({ filter: /^obsidian$/, namespace: "stub" }, () => ({ + contents: "export const getLanguage = () => 'en';", + loader: "js" + })); + } + } + ], + logLevel: "silent" +}); + +const { + buildCurrentObjectDiagnostics, + getExpectedHeaderForDiagnostic, + normalizeHexColorForPicker, + parseColorSchemeFile, + updateColorSchemeColorCell +} = await import("../" + outputFile + "?t=" + Date.now()); + +function colorSchemeMarkdown({ header = "| target | kind | fill | stroke | text | notes |", row = "| domain | system | #112233 | #445566 | #abcdef | Base |" } = {}) { + return `--- +type: color_scheme +id: COLOR-TEST +name: Color Test +--- +# Color Test + +## Colors + +${header} +|---|---|---|---|---|---| +${row} +`; +} + +function update(markdown, rowIndex, columnName, value) { + return updateColorSchemeColorCell(markdown, { rowIndex, columnName, value }); +} + +test("updates a fill cell in a valid color_scheme Colors table", () => { + const result = update(colorSchemeMarkdown(), 0, "fill", "#aabbcc"); + + assert.equal(result.status, "updated"); + assert.equal(result.changed, true); + assert.match(result.updatedMarkdown, /\| domain \| system \| #aabbcc \| #445566 \| #abcdef \| Base \|/); +}); + +test("updates a stroke cell in a valid color_scheme Colors table", () => { + const result = update(colorSchemeMarkdown(), 0, "stroke", "#0a1b2c"); + + assert.equal(result.status, "updated"); + assert.match(result.updatedMarkdown, /\| domain \| system \| #112233 \| #0a1b2c \| #abcdef \| Base \|/); +}); + +test("updates a text cell in a valid color_scheme Colors table", () => { + const result = update(colorSchemeMarkdown(), 0, "text", "#ffffff"); + + assert.equal(result.status, "updated"); + assert.match(result.updatedMarkdown, /\| domain \| system \| #112233 \| #445566 \| #ffffff \| Base \|/); +}); + +test("updates a blank color cell with a selected RRGGBB value", () => { + const markdown = colorSchemeMarkdown({ row: "| domain | system | | #445566 | #abcdef | Base |" }); + const result = update(markdown, 0, "fill", "#123abc"); + + assert.equal(result.status, "updated"); + assert.match(result.updatedMarkdown, /\| domain \| system \| #123abc \| #445566 \| #abcdef \| Base \|/); +}); + +test("accepts an existing RGB value and can replace it safely", () => { + const markdown = colorSchemeMarkdown({ row: "| domain | system | #abc | #445566 | #abcdef | Base |" }); + + assert.equal(normalizeHexColorForPicker("#abc"), "#aabbcc"); + const result = update(markdown, 0, "fill", "#ddeeff"); + + assert.equal(result.status, "updated"); + assert.match(result.updatedMarkdown, /\| domain \| system \| #ddeeff \| #445566 \| #abcdef \| Base \|/); +}); + +test("replaces unsupported existing color values without rewriting other cells", () => { + const markdown = colorSchemeMarkdown({ row: "| domain | system | blue | #445566 | #abcdef | Base |" }); + const result = update(markdown, 0, "fill", "#010203"); + + assert.equal(result.status, "updated"); + assert.match(result.updatedMarkdown, /\| domain \| system \| #010203 \| #445566 \| #abcdef \| Base \|/); +}); + +test("refuses to update a malformed Colors header", () => { + const markdown = colorSchemeMarkdown({ header: "| targeta | kind | fill | stroke | text | notes |" }); + const result = update(markdown, 0, "fill", "#010203"); + + assert.equal(result.status, "header-mismatch"); + assert.equal(result.changed, false); + assert.equal(result.updatedMarkdown, markdown); +}); + +test("malformed Colors header keeps schema-driven diagnostics behavior", () => { + const markdown = colorSchemeMarkdown({ + header: "| targeta | kind | fill | stroke | text | notes |", + row: "| domain | system | #112233 | #445566 | #abcdef | Base |\n| domain | system | #112233 | #445566 | #abcdef | Duplicate |" + }); + const parsed = parseColorSchemeFile(markdown, "COLOR-TEST.md"); + assert.ok(parsed.file); + + const diagnostics = buildCurrentObjectDiagnostics(parsed.file, { warningsByFilePath: {} }, null, parsed.warnings); + const headerDiagnostic = diagnostics.find((diagnostic) => diagnostic.code === "invalid-table-column"); + + assert.ok(headerDiagnostic); + assert.equal(headerDiagnostic.severity, "error"); + assert.equal(getExpectedHeaderForDiagnostic(headerDiagnostic), "| target | kind | fill | stroke | text | notes |"); + assert.equal( + diagnostics.some((diagnostic) => /duplicate Color Scheme entry/.test(diagnostic.message)), + false + ); +});