diff --git a/docs/design/README.md b/docs/design/README.md index 9f7fe891a..bf0adb5a3 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -96,12 +96,14 @@ ### Open tasks - drop the last inert traces of the shipped css/js once consumers have moved - off them: `GlobalParams::odr_core_data_path`, `HtmlConfig::resource_path`, - `embed_shipped_resources`, `relative_resource_paths`, - `HtmlResource::is_shipped` and their java, python and objc mirrors, plus - `OdrAndroid.init`, `ODRGlobalParams.bootstrapFromFrameworkBundle` and the - `ODR_BUNDLE_ASSETS` / conan `bundle_assets` option, all still accepted and - all doing nothing. + off them: `GlobalParams::odr_core_data_path` and its java, python and objc + mirrors, plus `OdrAndroid.init`, + `ODRGlobalParams.bootstrapFromFrameworkBundle` and the `ODR_BUNDLE_ASSETS` / + conan `bundle_assets` option, all still accepted and all doing nothing. They + are about *finding* a data directory, which nothing does any more — unlike + `HtmlConfig::embed_shipped_resources`, `resource_path`, + `relative_resource_paths` and `HtmlResource::is_shipped`, which decide where + the compiled-in css and js land and are live again. - drop the last inert traces of libmagic once consumers have moved off them: `GlobalParams::libmagic_database_path` and its java, python and objc mirrors still store and return a path nothing reads, and `ODR_WITH_LIBMAGIC` / diff --git a/src/odr/html.cpp b/src/odr/html.cpp index 49f5c15d8..2eb2a1072 100644 --- a/src/odr/html.cpp +++ b/src/odr/html.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include @@ -57,7 +56,6 @@ HtmlConfig::HtmlConfig(std::string output_path_) } void HtmlConfig::init() { - resource_path = GlobalParams::odr_core_data_path(); resource_locator = html::standard_resource_locator(); } @@ -248,12 +246,28 @@ HtmlService html::translate(const DecodedFile &file, const HtmlConfig &config, HtmlResourceLocator html::standard_resource_locator() { return [](const HtmlResource &resource, const HtmlConfig &config) -> HtmlResourceLocation { - // only an accessible image can be embedded; everything else is linked - if (resource.is_accessible() && config.embed_images && - resource.type() == HtmlResourceType::image) { + // what we cannot read can only be linked + if (!resource.is_accessible()) { + return resource.path(); + } + + if ((config.embed_shipped_resources && resource.is_shipped()) || + (config.embed_images && resource.type() == HtmlResourceType::image)) { return std::nullopt; } + if (resource.is_shipped()) { + Path path = Path(config.resource_path).join(RelPath(resource.path())); + // `rebase` throws across absolute and relative; a relative one is already + // relative to the document. + if (const Path output(config.output_path.value_or("")); + config.relative_resource_paths && config.output_path.has_value() && + path.absolute() == output.absolute()) { + path = path.rebase(output); + } + return path.string(); + } + return resource.path(); }; } diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 412b419a8..4598a147d 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -46,8 +46,8 @@ class HtmlResource final { [[nodiscard]] const std::string &name() const; [[nodiscard]] const std::string &path() const; [[nodiscard]] const std::optional &file() const; - /// @deprecated Inert: always false. The css and js are written into the - /// document, so no resource is shipped alongside it any more. + /// One of the renderer's own compiled-in css/js rather than something the + /// document carries. See @ref HtmlConfig::embed_shipped_resources. [[nodiscard]] bool is_shipped() const; [[nodiscard]] bool is_external() const; [[nodiscard]] bool is_accessible() const; @@ -104,14 +104,16 @@ struct HtmlConfig { // embedding bool embed_images{true}; - /// @deprecated Inert: nothing is shipped any more, the css and js are - /// written into the document. + /// Write the renderer's own css and js into every document rather than beside + /// it as one shared file the documents link. bool embed_shipped_resources{true}; // resources - /// @deprecated See @ref embed_shipped_resources. + /// Where linked shipped resources go, relative to the output path unless + /// named absolutely. Empty puts them beside the document. std::string resource_path; - /// @deprecated See @ref embed_shipped_resources. + /// Link an absolute @ref resource_path relative to the document, so the + /// output stays movable. bool relative_resource_paths{true}; // create editable output diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index 73ef2659e..062d4764b 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -32,7 +32,10 @@ bool is_paged_content(const Document &document, const HtmlConfig &config) { document.document_type() == DocumentType::drawing; } -void front(const Document &document, const WritingState &state) { +/// @p name titles the view; empty when the whole document is written as one +/// file, which no one view names. +void front(const Document &document, const WritingState &state, + const std::string &name) { HtmlWriter &out = state.out(); const bool paged_content = is_paged_content(document, state.config()); @@ -41,15 +44,18 @@ void front(const Document &document, const WritingState &state) { out.write_header_begin(); out.write_header_charset("UTF-8"); out.write_header_target("_blank"); - out.write_header_title("odr"); + out.write_header_title( + document.document_type() == DocumentType::spreadsheet && !name.empty() + ? escape_text(name) + : "odr"); write_viewport_meta(out, state.config(), paged_content, document.document_type() == DocumentType::spreadsheet ? state.config().spreadsheet_viewport_mode : std::nullopt); - write_document_style(out); + write_document_style(state); if (document.document_type() == DocumentType::spreadsheet) { - write_spreadsheet_style(out); + write_spreadsheet_style(state); } out.write_header_end(); @@ -87,7 +93,10 @@ void back(const Document &document, const WritingState &state) { out.write_element_end("div"); } - write_document_script(out); + write_document_script(state); + if (document.document_type() == DocumentType::spreadsheet) { + write_spreadsheet_script(state); + } out.write_body_end(); out.write_end(); @@ -109,7 +118,7 @@ class HtmlFragmentBase { virtual void write_fragment(HtmlWriter &out, WritingState &state) const = 0; void write_document(HtmlWriter &out, WritingState &state) const { - front(m_document, state); + front(m_document, state, m_name); write_fragment(out, state); back(m_document, state); } @@ -197,14 +206,7 @@ class HtmlServiceImpl final : public HtmlService { warmup(); - if (std::ranges::any_of(m_resources, [&path](const auto &pair) { - const auto &[resource, location] = pair; - return location.has_value() && location.value() == path; - })) { - return true; - } - - return false; + return resource_at(m_resources, path) != nullptr; } std::string mimetype(const std::string &path) const override { @@ -216,10 +218,9 @@ class HtmlServiceImpl final : public HtmlService { warmup(); - for (const auto &[resource, location] : m_resources) { - if (location.has_value() && location.value() == path) { - return resource.mime_type(); - } + if (const odr::HtmlResource *resource = resource_at(m_resources, path); + resource != nullptr) { + return resource->mime_type(); } throw FileNotFound("Unknown path: " + path); @@ -236,11 +237,10 @@ class HtmlServiceImpl final : public HtmlService { warmup(); - for (const auto &[resource, location] : m_resources) { - if (location.has_value() && location.value() == path) { - resource.write_resource(out); - return; - } + if (const odr::HtmlResource *resource = resource_at(m_resources, path); + resource != nullptr) { + resource->write_resource(out); + return; } throw FileNotFound("Unknown path: " + path); @@ -266,7 +266,7 @@ class HtmlServiceImpl final : public HtmlService { WritingState state(out, config(), resources); - front(m_document, state); + front(m_document, state, ""); for (const auto &fragment : m_fragments) { fragment->write_fragment(out, state); } diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index 1630a9b9b..5a041cbc6 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -79,10 +79,8 @@ void html::translate_element(const Element &element, } void html::translate_sheet(const Sheet &sheet, const WritingState &state) { - state.out().write_element_begin( - "table", - HtmlElementOptions().set_attributes(HtmlAttributesVector{ - {"cellpadding", "0"}, {"border", "0"}, {"cellspacing", "0"}})); + state.out().write_element_begin("table", + HtmlElementOptions().set_class("odr-sheet")); const TableDimensions dimensions = sheet.dimensions(); std::uint32_t end_column = dimensions.columns; @@ -101,8 +99,10 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { end_column = std::max(1u, end_column); end_row = std::max(1u, end_row); - state.out().write_element_begin( - "col", HtmlElementOptions().set_close_type(HtmlCloseType::none)); + state.out().write_element_begin("col", + HtmlElementOptions() + .set_close_type(HtmlCloseType::none) + .set_class("odr-sheet-gutter")); for (std::uint32_t column_index = 0; column_index < end_column; ++column_index) { @@ -116,26 +116,41 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { .set_style(translate_table_column_style(table_column_style))); } + // No `scope`: the letters and numbers are a ruler, not headers of what they + // label. { + state.out().write_element_begin("thead"); state.out().write_element_begin("tr"); - state.out().write_element_begin("td", - HtmlElementOptions() - .set_close_type(HtmlCloseType::trailing) - .set_style("width:30px;height:20px;")); + // Under `table-layout:fixed` the first row sizes the columns, and `ch` + // resolves against the ruler's font — so the gutter width sits here rather + // than on the ``. + state.out().write_element_begin( + "th", + HtmlElementOptions() + .set_inline(true) + .set_class("odr-sheet-corner") + .set_style("width:calc(" + + std::to_string( + TablePosition::to_row_string(end_row - 1).size()) + + "ch + 14px);")); + state.out().write_element_end("th"); for (std::uint32_t column_index = 0; column_index < end_column; ++column_index) { state.out().write_element_begin( - "td", HtmlElementOptions().set_inline(true).set_style( - "text-align:center;vertical-align:middle;")); + "th", HtmlElementOptions().set_inline(true).set_class( + "odr-sheet-column-header")); state.out().write_raw(TablePosition::to_column_string(column_index)); - state.out().write_element_end("td"); + state.out().write_element_end("th"); } state.out().write_element_end("tr"); + state.out().write_element_end("thead"); } + state.out().write_element_begin("tbody"); + TableCursor cursor; for (std::uint32_t row_index = cursor.row(); row_index < end_row; row_index = cursor.row()) { @@ -146,17 +161,20 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { translate_table_row_style(table_row_style))); state.out().write_element_begin( - "td", HtmlElementOptions().set_inline(true).set_style([&] { - std::string style = "text-align:center;vertical-align:middle;"; - if (const std::optional height = table_row_style.height; - height.has_value()) { - style += "height:" + height->to_string() + ";"; - style += "max-height:" + height->to_string() + ";"; - } - return style; - }())); + "th", HtmlElementOptions() + .set_inline(true) + .set_class("odr-sheet-row-header") + .set_style([&]() -> std::optional { + const std::optional height = + table_row_style.height; + if (!height.has_value()) { + return std::nullopt; + } + return "height:" + height->to_string() + + ";max-height:" + height->to_string() + ";"; + }())); state.out().write_raw(TablePosition::to_row_string(row_index)); - state.out().write_element_end("td"); + state.out().write_element_end("th"); for (std::uint32_t column_index = cursor.column(); column_index < end_column; column_index = cursor.column()) { @@ -211,6 +229,7 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { cursor.add_row(); } + state.out().write_element_end("tbody"); state.out().write_element_end("table"); } diff --git a/src/odr/internal/html/filesystem.cpp b/src/odr/internal/html/filesystem.cpp index 79a824744..839b6edf7 100644 --- a/src/odr/internal/html/filesystem.cpp +++ b/src/odr/internal/html/filesystem.cpp @@ -7,18 +7,42 @@ #include #include #include +#include #include #include +#include +#include +#include + namespace odr::internal::html { namespace { +/// Binary multiples, named as such — nothing to guess the base of. +std::string human_size(const std::size_t size) { + static constexpr std::array units{"B", "KiB", "MiB", "GiB", + "TiB"}; + + double value = static_cast(size); + std::size_t unit = 0; + while (value >= 1024.0 && unit + 1 < units.size()) { + value /= 1024.0; + ++unit; + } + + std::ostringstream result; + result << std::fixed << std::setprecision(unit == 0 ? 0 : 1) << value << " " + << units.at(unit); + return result.str(); +} + class HtmlServiceImpl final : public HtmlService { public: HtmlServiceImpl(Filesystem filesystem, HtmlConfig config, const Logger &logger) : HtmlService(std::move(config), logger), - m_filesystem{std::move(filesystem)} { + m_filesystem{std::move(filesystem)}, + m_resources{locate_filesystem_resources(this->config())} { m_views.emplace_back( std::make_shared(*this, "files", 0, "files.html")); } @@ -28,13 +52,17 @@ class HtmlServiceImpl final : public HtmlService { [[nodiscard]] const HtmlViews &list_views() const override { return m_views; } [[nodiscard]] bool exists(const std::string &path) const override { - return path == "files.html"; + return path == "files.html" || resource_at(m_resources, path) != nullptr; } [[nodiscard]] std::string mimetype(const std::string &path) const override { if (path == "files.html") { return "text/html"; } + if (const odr::HtmlResource *resource = resource_at(m_resources, path); + resource != nullptr) { + return resource->mime_type(); + } throw FileNotFound("Unknown path: " + path); } @@ -45,6 +73,11 @@ class HtmlServiceImpl final : public HtmlService { write_filesystem(writer); return; } + if (const odr::HtmlResource *resource = resource_at(m_resources, path); + resource != nullptr) { + resource->write_resource(out); + return; + } throw FileNotFound("Unknown path: " + path); } @@ -60,6 +93,7 @@ class HtmlServiceImpl final : public HtmlService { HtmlResources write_filesystem(HtmlWriter &out) const { HtmlResources resources; + const WritingState state(out, config(), resources); const FileWalker file_walker = m_filesystem.file_walker("/"); @@ -70,53 +104,73 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_target("_blank"); out.write_header_title("odr"); write_viewport_meta(out, config(), false); - out.write_header_style_begin(); - out.write_raw("*{font-family:monospace;}"); - out.write_header_style_end(); + write_filesystem_style(state); out.write_header_end(); out.write_body_begin(); - const auto span = [&out](const HtmlWritable &content) { - out.write_element_begin("span"); - out.write_raw(content); - out.write_element_end("span"); - }; + out.write_element_begin("table", + HtmlElementOptions().set_class("odr-files")); + + // No header row: labels would be the only words in the page to translate. + out.write_element_begin("tbody"); for (; !file_walker.end(); file_walker.next()) { const Path file_path(file_walker.path()); const bool is_file = file_walker.is_file(); - out.write_element_begin("p"); - - span(escape_text(file_path.string())); - span(" "); - span(is_file ? "file" : "directory"); - + out.write_element_begin("tr", HtmlElementOptions().set_class( + is_file ? std::nullopt + : std::optional( + "odr-files-directory"))); + + // A trailing separator says "directory" without a column saying so. + out.write_element_begin( + "td", + HtmlElementOptions().set_inline(true).set_class("odr-files-name")); + out.write_raw( + escape_text(is_file ? file_path.string() : file_path.string() + "/")); + out.write_element_end("td"); + + const File file = + is_file ? m_filesystem.open(file_path.string()) : File(); + + out.write_element_begin( + "td", + HtmlElementOptions().set_inline(true).set_class("odr-files-size")); if (is_file) { - span(" "); - - File file = m_filesystem.open(file_path.string()); - - span(std::to_string(file.size())); + out.write_raw(human_size(file.size())); + } + out.write_element_end("td"); + out.write_element_begin( + "td", + HtmlElementOptions().set_inline(true).set_class("odr-files-action")); + if (is_file) { if (const std::unique_ptr stream = file.stream(); stream != nullptr) { - span(" "); - + const std::string name = file_path.basename(); + // The glyph has no name of its own; the file's is what a tooltip and + // a screen reader read. out.write_element_begin( - "a", - HtmlElementOptions().set_attributes(HtmlAttributesVector{ - {"href", file_to_url(*stream, "application/octet-stream")}, - {"download", escape_attribute(file_path.basename())}})); - out.write_raw("download"); + "a", HtmlElementOptions().set_inline(true).set_attributes( + HtmlAttributesVector{ + {"href", + file_to_url(*stream, "application/octet-stream")}, + {"download", escape_attribute(name)}, + {"title", escape_attribute(name)}})); + out.write_raw("\u2193"); out.write_element_end("a"); } } + out.write_element_end("td"); - out.write_element_end("p"); + out.write_element_end("tr"); } + out.write_element_end("tbody"); + out.write_element_end("table"); + out.write_body_end(); out.write_end(); @@ -126,6 +180,8 @@ class HtmlServiceImpl final : public HtmlService { protected: Filesystem m_filesystem; + /// The css this view links; empty of locations when the config embeds it. + HtmlResources m_resources; HtmlViews m_views; }; diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index d574ff9d7..6f8a0aae1 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -1,14 +1,23 @@ #include +#include +#include + +#include +#include #include +#include +#include +#include +#include + namespace odr::internal::html { namespace { -constexpr const char *document_css = R"css( +constexpr std::string_view document_css = R"css( *{margin:0;position:relative} -body{padding:5px} x-p{display:block;font-size:0} x-s{display:inline} .odr-background{padding:0;background:#525659} @@ -22,17 +31,52 @@ mark{background:#ff0} mark.current{background:orange} )css"; -constexpr const char *spreadsheet_css = R"css( +/// No `text-overflow`: it sat on `td`, whose overflow is visible, and making it +/// work would mean clipping. +constexpr std::string_view spreadsheet_css = R"css( +:root{ +--odr-sheet-line:#e3e5e8; +--odr-sheet-rule:#c8ccd1; +--odr-sheet-ruler:#f5f6f7; +--odr-sheet-ruler-text:#5c6169; +--odr-sheet-canvas:#eceef1; +--odr-sheet-wash:rgba(0,0,0,.045); +--odr-sheet-wash-pinned:rgba(0,0,0,.09); +--odr-sheet-wash-ruler:rgba(0,0,0,.10); +--odr-sheet-focus:#3c78dc; +--odr-sheet-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; +} +/* A sheet is not a page: past the last row and column is canvas. */ +body{background:var(--odr-sheet-canvas)} +.odr-sheet{background:#fff} table{border-collapse:collapse;table-layout:fixed} -td{vertical-align:bottom;text-overflow:ellipsis;height:inherit} -x-p{font-family:"Arial",serif;font-size:10pt} +td{vertical-align:bottom;height:inherit;padding:1px 6px} +x-p{font-family:var(--odr-sheet-font);font-size:10pt} td x-p{height:inherit} -.odr-gridlines-soft table td{border-top:1px solid #c0c0c0;border-left:1px solid #c0c0c0} -.odr-gridlines-hard table td{border:1px solid #c0c0c0!important} -table td.odr-value-type-float{text-align:right} +/* Sticky cells in a collapsed border model do not repaint their borders in + Chrome or WebKit, so the ruler uses inset shadows. */ +.odr-sheet th{position:sticky;background:var(--odr-sheet-ruler);color:var(--odr-sheet-ruler-text);font:500 12px/1.6 var(--odr-sheet-font);text-align:center;vertical-align:middle;padding:0 4px;white-space:nowrap;user-select:none} +.odr-sheet thead th{top:0;z-index:2;height:24px;box-shadow:inset 0 -1px 0 var(--odr-sheet-rule),inset -1px 0 0 var(--odr-sheet-line)} +.odr-sheet-row-header{left:0;z-index:1;box-shadow:inset -1px 0 0 var(--odr-sheet-rule),inset 0 -1px 0 var(--odr-sheet-line)} +.odr-sheet-corner{left:0;z-index:3;box-shadow:inset -1px 0 0 var(--odr-sheet-rule),inset 0 -1px 0 var(--odr-sheet-rule)} +.odr-gridlines-soft .odr-sheet td{border-top:1px solid var(--odr-sheet-line);border-left:1px solid var(--odr-sheet-line)} +.odr-gridlines-hard .odr-sheet td{border:1px solid var(--odr-sheet-line)!important} +.odr-sheet td.odr-value-type-float{text-align:right} +/* `background-image` layers over the document's own cell background instead of + replacing it, and leaves `box-shadow` to the ruler. */ +.odr-sheet tbody tr:hover>*{background-image:linear-gradient(var(--odr-sheet-wash),var(--odr-sheet-wash))} +.odr-sheet tbody tr.odr-sheet-pinned>*{background-image:linear-gradient(var(--odr-sheet-wash-pinned),var(--odr-sheet-wash-pinned))} +.odr-sheet tbody tr:hover>th,.odr-sheet tbody tr.odr-sheet-pinned>th{background-image:linear-gradient(var(--odr-sheet-wash-ruler),var(--odr-sheet-wash-ruler))} +.odr-sheet .odr-sheet-pinned-cell{outline:2px solid var(--odr-sheet-focus);outline-offset:-2px} +/* `*{position:relative}` already makes the header a containing block. */ +.odr-sheet-sort{position:absolute;top:1px;right:1px;bottom:1px;width:17px;display:flex;align-items:center;justify-content:center;border-radius:2px;opacity:0;cursor:pointer} +.odr-sheet-column-header:hover .odr-sheet-sort,.odr-sheet-sort-asc,.odr-sheet-sort-desc{opacity:1} +.odr-sheet-sort:hover{background:var(--odr-sheet-wash-ruler)} +.odr-sheet-sort::after{content:"\25BE";font-size:15px;line-height:1} +.odr-sheet-sort-asc::after{content:"\25B4"} )css"; -constexpr const char *text_css = R"css( +constexpr std::string_view text_css = R"css( .odr-text{display:flex;flex-direction:row;font-family:monospace} .odr-text-nr{display:flex;flex-direction:column;text-align:right;vertical-align:top;color:#999;border-right:solid #999} .odr-text-body{display:flex;flex-direction:column;padding-left:5pt;white-space:pre} @@ -40,13 +84,34 @@ constexpr const char *text_css = R"css( [contenteditable]:focus{outline:none} )css"; -constexpr const char *media_css = R"css( +constexpr std::string_view filesystem_css = R"css( +:root{ +--odr-files-line:#e3e5e8; +--odr-files-muted:#5c6169; +--odr-files-link:#3c78dc; +--odr-files-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; +--odr-files-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; +} +body{background:#fff;color:#1f2328;font:13px/1.5 var(--odr-files-font)} +.odr-files{border-collapse:collapse;width:100%} +.odr-files td{padding:5px 12px;border-top:1px solid var(--odr-files-line)} +.odr-files tbody tr:hover>*{background-image:linear-gradient(rgba(0,0,0,.04),rgba(0,0,0,.04))} +.odr-files-name{font-family:var(--odr-files-mono);word-break:break-all} +.odr-files-directory .odr-files-name{color:var(--odr-files-muted)} +.odr-files-size{width:1%;text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums;color:var(--odr-files-muted)} +.odr-files-action{width:1%;white-space:nowrap} +/* U+2193: the download glyphs are missing from enough system fonts to tofu. */ +.odr-files-action a{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:4px;color:var(--odr-files-muted);font-size:15px;line-height:1;text-decoration:none} +.odr-files-action a:hover{background:rgba(0,0,0,.07);color:var(--odr-files-link)} +)css"; + +constexpr std::string_view media_css = R"css( .odr-media{display:flex;align-items:center;justify-content:center;margin:0;min-height:100vh;background:#000} .odr-media video{max-width:100%;max-height:100vh} .odr-media audio{width:100%;max-width:40rem;margin:0 1rem} )css"; -constexpr const char *document_js = R"js( +constexpr std::string_view document_js = R"js( (function () { "use strict"; @@ -227,9 +292,254 @@ constexpr const char *document_js = R"js( })(); )js"; +/// Highlights the row and column under the pointer, and pins them on a click. +/// A column has no `:hover` selector, so one generated `:nth-child` rule lights +/// it — free per cell, but only correct while cell and column line up. +constexpr std::string_view spreadsheet_js = R"js( +(function () { + "use strict"; + + var table = document.querySelector(".odr-sheet"); + if (table === null) { + return; + } + + var merged = table.querySelector("td[colspan],td[rowspan]") !== null; + + var style = document.createElement("style"); + document.head.appendChild(style); + + var hovered = -1; + var pinnedColumn = -1; + var pinnedRow = null; + var pinnedCell = null; + + // Column 0 is the gutter, which labels no column. + function columnRule(index, wash, scope) { + if (index < 1) { + return ""; + } + return ( + ".odr-sheet " + + scope + + "tr>:nth-child(" + + (index + 1) + + "){background-image:linear-gradient(" + + wash + + "," + + wash + + ")}" + ); + } + + // The ruler reacts harder than the cells: it is the label being followed. + function paint() { + style.textContent = + columnRule(hovered, "var(--odr-sheet-wash)", "") + + columnRule(pinnedColumn, "var(--odr-sheet-wash-pinned)", "") + + columnRule(hovered, "var(--odr-sheet-wash-ruler)", "thead ") + + columnRule(pinnedColumn, "var(--odr-sheet-wash-ruler)", "thead "); + } + + function columnOf(cell) { + return cell !== null && !merged ? cell.cellIndex : -1; + } + + function pin(column, row, cell) { + if (pinnedRow !== null) { + pinnedRow.classList.remove("odr-sheet-pinned"); + } + if (pinnedCell !== null) { + pinnedCell.classList.remove("odr-sheet-pinned-cell"); + } + + pinnedColumn = column; + pinnedRow = row; + pinnedCell = cell; + + if (pinnedRow !== null) { + pinnedRow.classList.add("odr-sheet-pinned"); + } + if (pinnedCell !== null) { + pinnedCell.classList.add("odr-sheet-pinned-cell"); + } + paint(); + } + + table.addEventListener("mouseover", function (event) { + var column = columnOf(event.target.closest("td,th")); + if (column !== hovered) { + hovered = column; + paint(); + } + }); + + table.addEventListener("mouseleave", function () { + hovered = -1; + paint(); + }); + + table.addEventListener("click", function (event) { + var cell = event.target.closest("td,th"); + if (cell === null) { + return; + } + + // Clicking what is pinned clears it. + if (cell === pinnedCell) { + pin(-1, null, null); + return; + } + + if (cell.classList.contains("odr-sheet-column-header")) { + pin(columnOf(cell), null, cell); + } else if (cell.classList.contains("odr-sheet-row-header")) { + pin(-1, cell.parentElement, cell); + } else if (cell.classList.contains("odr-sheet-corner")) { + pin(-1, null, null); + } else { + pin(columnOf(cell), cell.parentElement, cell); + } + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + pin(-1, null, null); + } + }); + + var body = table.tBodies[0]; + var original = null; + var sortedColumn = -1; + var sortedDirection = 0; + + // Only the rendered text is in the markup, not the number behind it. The last + // separator is the decimal one, which settles 1,234.56 against 1.234,56. + function toNumber(text) { + var cleaned = text.replace(/[^0-9,.eE+-]/g, ""); + if (cleaned.lastIndexOf(",") > cleaned.lastIndexOf(".")) { + cleaned = cleaned.replace(/\./g, "").replace(",", "."); + } else { + cleaned = cleaned.replace(/,/g, ""); + } + var value = parseFloat(cleaned); + return isFinite(value) ? value : NaN; + } + + // Numbers, then text, then blanks: no column is forced into one kind. + var NUMBER = 0; + var TEXT = 1; + var BLANK = 2; + + function keyOf(row, index) { + var cell = row.children[index]; + var text = cell === undefined ? "" : cell.textContent.trim(); + if (text === "") { + return { rank: BLANK, value: 0 }; + } + if (cell.classList.contains("odr-value-type-float")) { + var value = toNumber(text); + if (!isNaN(value)) { + return { rank: NUMBER, value: value }; + } + } + return { rank: TEXT, value: text }; + } + + function reorder(rows) { + var fragment = document.createDocumentFragment(); + for (var i = 0; i < rows.length; ++i) { + fragment.appendChild(rows[i]); + } + body.appendChild(fragment); + } + + function sortBy(index, direction) { + if (original === null) { + original = Array.prototype.slice.call(body.rows); + } + if (direction === 0) { + reorder(original); + return; + } + + var rows = Array.prototype.slice.call(body.rows); + var keys = new Map(); + for (var i = 0; i < rows.length; ++i) { + keys.set(rows[i], keyOf(rows[i], index)); + } + + // A blank is an absent value, not the smallest one, so it stays last either + // way round. The stable sort keeps the document's order for ties. + rows.sort(function (a, b) { + var x = keys.get(a); + var y = keys.get(b); + if (x.rank === BLANK || y.rank === BLANK) { + return x.rank === y.rank ? 0 : x.rank === BLANK ? 1 : -1; + } + if (x.rank !== y.rank) { + return (x.rank - y.rank) * direction; + } + var result = + x.rank === NUMBER + ? x.value - y.value + : x.value.localeCompare(y.value, undefined, { numeric: true }); + return result * direction; + }); + reorder(rows); + } + + // A `rowspan` would reach into a row no longer beneath it and a `colspan` + // breaks the column index, so a merged sheet gets no sort control. + if (!merged) { + var headers = table.tHead.rows[0].children; + for (var column = 1; column < headers.length; ++column) { + var control = document.createElement("span"); + control.className = "odr-sheet-sort"; + control.setAttribute("title", "sort by column " + headers[column].textContent); + headers[column].appendChild(control); + } + + table.addEventListener( + "click", + function (event) { + var control = event.target.closest(".odr-sheet-sort"); + if (control === null) { + return; + } + // The header itself pins the column; only this control sorts it. + event.stopPropagation(); + + var index = control.parentElement.cellIndex; + var direction = + index !== sortedColumn ? 1 : sortedDirection === 1 ? -1 : 0; + + sortBy(index, direction); + + control.classList.remove("odr-sheet-sort-asc", "odr-sheet-sort-desc"); + if (direction === 1) { + control.classList.add("odr-sheet-sort-asc"); + } else if (direction === -1) { + control.classList.add("odr-sheet-sort-desc"); + } + if (sortedColumn !== index && sortedColumn >= 0) { + headers[sortedColumn] + .querySelector(".odr-sheet-sort") + .classList.remove("odr-sheet-sort-asc", "odr-sheet-sort-desc"); + } + + sortedColumn = direction === 0 ? -1 : index; + sortedDirection = direction; + }, + true + ); + } +})(); +)js"; + /// Every input is applied to the line `
`s by hand, so the line numbers /// stay in step and undo/redo replay changes instead of the browser's history. -constexpr const char *text_js = R"js( +constexpr std::string_view text_js = R"js( (function () { "use strict"; @@ -562,16 +872,77 @@ constexpr const char *text_js = R"js( })(); )js"; -void write_style(HtmlWriter &out, const char *css) { - out.write_header_style_begin(); - out.out() << css; - out.write_header_style_end(); +/// One of the renderer's own stylesheets or scripts — "shipped" in the sense +/// @ref odr::HtmlConfig::embed_shipped_resources means. +struct Asset { + HtmlResourceType type; + std::string_view mime_type; + std::string_view name; + std::string_view content; +}; + +constexpr Asset document_css_asset{HtmlResourceType::css, "text/css", + "document.css", document_css}; +constexpr Asset spreadsheet_css_asset{HtmlResourceType::css, "text/css", + "spreadsheet.css", spreadsheet_css}; +constexpr Asset text_css_asset{HtmlResourceType::css, "text/css", "text.css", + text_css}; +constexpr Asset filesystem_css_asset{HtmlResourceType::css, "text/css", + "filesystem.css", filesystem_css}; +constexpr Asset media_css_asset{HtmlResourceType::css, "text/css", "media.css", + media_css}; +constexpr Asset document_js_asset{HtmlResourceType::js, "text/javascript", + "document.js", document_js}; +constexpr Asset spreadsheet_js_asset{HtmlResourceType::js, "text/javascript", + "spreadsheet.js", spreadsheet_js}; +constexpr Asset text_js_asset{HtmlResourceType::js, "text/javascript", + "text.js", text_js}; + +/// Appends @p asset to @p resources; `nullopt` to embed it. +HtmlResourceLocation locate(const Asset &asset, const HtmlConfig &config, + HtmlResources &resources) { + const odr::HtmlResource resource = HtmlResource::create( + asset.type, std::string(asset.mime_type), std::string(asset.name), + std::string(asset.name), + odr::File::from_memory(std::string(asset.content)), true, false, true); + HtmlResourceLocation location = config.resource_locator(resource, config); + resources.emplace_back(resource, location); + return location; +} + +HtmlResources locate_all(const std::span assets, + const HtmlConfig &config) { + HtmlResources resources; + for (const Asset &asset : assets) { + locate(asset, config, resources); + } + return resources; } -void write_script(HtmlWriter &out, const char *js) { - out.write_script_begin(); - out.out() << js; - out.write_script_end(); +void write_style(const Asset &asset, const WritingState &state) { + if (const HtmlResourceLocation location = + locate(asset, state.config(), state.resources()); + location.has_value()) { + state.out().write_header_style(escape_attribute(*location)); + return; + } + + state.out().write_header_style_begin(); + state.out().out() << asset.content; + state.out().write_header_style_end(); +} + +void write_script(const Asset &asset, const WritingState &state) { + if (const HtmlResourceLocation location = + locate(asset, state.config(), state.resources()); + location.has_value()) { + state.out().write_script(escape_attribute(*location)); + return; + } + + state.out().write_script_begin(); + state.out().out() << asset.content; + state.out().write_script_end(); } } // namespace @@ -580,22 +951,51 @@ void write_script(HtmlWriter &out, const char *js) { namespace odr::internal { -void html::write_document_style(HtmlWriter &out) { - write_style(out, document_css); +void html::write_document_style(const WritingState &state) { + write_style(document_css_asset, state); } -void html::write_spreadsheet_style(HtmlWriter &out) { - write_style(out, spreadsheet_css); +void html::write_spreadsheet_style(const WritingState &state) { + write_style(spreadsheet_css_asset, state); } -void html::write_text_style(HtmlWriter &out) { write_style(out, text_css); } +void html::write_text_style(const WritingState &state) { + write_style(text_css_asset, state); +} + +void html::write_filesystem_style(const WritingState &state) { + write_style(filesystem_css_asset, state); +} -void html::write_media_style(HtmlWriter &out) { write_style(out, media_css); } +void html::write_media_style(const WritingState &state) { + write_style(media_css_asset, state); +} -void html::write_document_script(HtmlWriter &out) { - write_script(out, document_js); +void html::write_document_script(const WritingState &state) { + write_script(document_js_asset, state); } -void html::write_text_script(HtmlWriter &out) { write_script(out, text_js); } +void html::write_spreadsheet_script(const WritingState &state) { + write_script(spreadsheet_js_asset, state); +} + +void html::write_text_script(const WritingState &state) { + write_script(text_js_asset, state); +} + +HtmlResources html::locate_text_resources(const HtmlConfig &config) { + static constexpr std::array assets{text_css_asset, text_js_asset}; + return locate_all(assets, config); +} + +HtmlResources html::locate_filesystem_resources(const HtmlConfig &config) { + static constexpr std::array assets{filesystem_css_asset}; + return locate_all(assets, config); +} + +HtmlResources html::locate_media_resources(const HtmlConfig &config) { + static constexpr std::array assets{media_css_asset}; + return locate_all(assets, config); +} } // namespace odr::internal diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index f6a17fa9f..35f74a45a 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -1,20 +1,33 @@ #pragma once +#include + namespace odr::internal::html { -class HtmlWriter; +struct WritingState; -/// Each of these writes one complete `