mirror of
https://github.com/vcmi/vcmi.git
synced 2026-05-22 09:55:17 +02:00
implement anchors for wiki
This commit is contained in:
@@ -234,6 +234,12 @@ void CViewport::fitContentSize()
|
||||
setContentSize(Point(maxX, maxY));
|
||||
}
|
||||
|
||||
void CViewport::scrollToY(int y)
|
||||
{
|
||||
if(vSlider && !vSlider->isDisabled())
|
||||
vSlider->scrollTo(std::max(0, y));
|
||||
}
|
||||
|
||||
void CViewport::onScrollV(int val)
|
||||
{
|
||||
const int delta = val - scrollY;
|
||||
|
||||
@@ -89,4 +89,9 @@ public:
|
||||
/// Sets content size to tight bounding box of all children, then
|
||||
/// re-evaluates scrollbars. Call after populating via content().
|
||||
void fitContentSize();
|
||||
|
||||
/// Scrolls the viewport so that content at vertical pixel offset @p y
|
||||
/// is visible at the top of the viewport. Does nothing if there is no
|
||||
/// vertical slider or the content fits without scrolling.
|
||||
void scrollToY(int y);
|
||||
};
|
||||
|
||||
@@ -297,6 +297,27 @@ static std::optional<MDAlignTag> parseAlignTag(const std::string & line)
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Parses an <a id="name" /> or <a name="name" /> HTML anchor tag anywhere in
|
||||
// a string and returns the first captured name/id value (lower-cased).
|
||||
// Returns an empty string if no tag is found.
|
||||
static std::string parseAnchorTag(const std::string & s)
|
||||
{
|
||||
static const std::regex RE(R"re(<a\s+(?:id|name)\s*=\s*"([^"]*?)"\s*/>)re",
|
||||
std::regex::icase);
|
||||
std::smatch m;
|
||||
if(std::regex_search(s, m, RE))
|
||||
return m[1].str();
|
||||
return {};
|
||||
}
|
||||
|
||||
// Removes all <a id="..."/> / <a name="..."/> tags from a string.
|
||||
static std::string stripAnchorTags(const std::string & s)
|
||||
{
|
||||
static const std::regex RE(R"re(<a\s+(?:id|name)\s*=\s*"[^"]*?"\s*/>)re",
|
||||
std::regex::icase);
|
||||
return std::regex_replace(s, RE, "");
|
||||
}
|
||||
|
||||
struct ParsedMedia
|
||||
{
|
||||
std::string path;
|
||||
@@ -426,7 +447,8 @@ std::vector<std::shared_ptr<CIntObject>> buildMarkdownContent(
|
||||
const std::string & markdownText,
|
||||
int viewportWidth,
|
||||
bool blueStyle,
|
||||
std::function<void(const std::string &)> onWikiLink)
|
||||
std::function<void(const std::string &)> onWikiLink,
|
||||
std::map<std::string, int> * anchors)
|
||||
{
|
||||
std::vector<std::shared_ptr<CIntObject>> widgets;
|
||||
OBJECT_CONSTRUCTION_TARGETED(viewport.content());
|
||||
@@ -874,6 +896,29 @@ std::vector<std::shared_ptr<CIntObject>> buildMarkdownContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Invisible named anchor: <a id="name" /> or <a name="name" />
|
||||
// Records the current Y position in the anchors map and emits no widget.
|
||||
// Anchor tags may also appear inside heading lines (handled in the heading
|
||||
// branch below); this branch catches standalone anchor-only lines.
|
||||
{
|
||||
const std::string anchorId = parseAnchorTag(t);
|
||||
if(!anchorId.empty())
|
||||
{
|
||||
// Only treat as a standalone anchor if the whole line reduces to
|
||||
// nothing after stripping the tag (no other content).
|
||||
const std::string stripped = stripAnchorTags(t);
|
||||
std::string rest = stripped;
|
||||
while(!rest.empty() && (rest.front() == ' ' || rest.front() == '\t')) rest.erase(rest.begin());
|
||||
while(!rest.empty() && (rest.back() == ' ' || rest.back() == '\t')) rest.pop_back();
|
||||
if(rest.empty())
|
||||
{
|
||||
if(anchors) (*anchors)[anchorId] = curY;
|
||||
continue;
|
||||
}
|
||||
// Otherwise fall through – line has content beyond the anchor tag.
|
||||
}
|
||||
}
|
||||
|
||||
// <p> – explicit paragraph break with gap.
|
||||
if(t == "<p>" || t == "<P>")
|
||||
{
|
||||
@@ -896,6 +941,18 @@ std::vector<std::shared_ptr<CIntObject>> buildMarkdownContent(
|
||||
if(level > 0)
|
||||
{
|
||||
flushPara();
|
||||
// Record any embedded anchor at the heading's current Y.
|
||||
if(anchors)
|
||||
{
|
||||
const std::string anchorId = parseAnchorTag(headText);
|
||||
if(!anchorId.empty())
|
||||
(*anchors)[anchorId] = curY;
|
||||
}
|
||||
// Strip anchor tags so they don't appear in the rendered text.
|
||||
headText = stripAnchorTags(headText);
|
||||
while(!headText.empty() && (headText.front() == ' ' || headText.front() == '\t')) headText.erase(headText.begin());
|
||||
while(!headText.empty() && (headText.back() == ' ' || headText.back() == '\t')) headText.pop_back();
|
||||
|
||||
const EFonts fnt = (level == 1) ? FONT_BIG : (level == 2) ? FONT_MEDIUM : FONT_SMALL;
|
||||
const int topPad = (level == 1) ? MD_H1_PAD_TOP : (level == 2) ? MD_H2_PAD_TOP : MD_H3_PAD_TOP;
|
||||
const int lineH = (level == 1) ? 22 : (level == 2) ? 16 : 12;
|
||||
|
||||
@@ -9,11 +9,6 @@
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class CIntObject;
|
||||
class CViewport;
|
||||
|
||||
@@ -52,11 +47,21 @@ class CViewport;
|
||||
/// The id for glossary entries is the translation-key
|
||||
/// base without the trailing .name suffix, e.g.:
|
||||
/// wiki:glossary/vcmi.wiki.glossary.mdtest
|
||||
/// Append #anchorname to scroll the target page to a
|
||||
/// named anchor:
|
||||
/// wiki:glossary/vcmi.wiki.glossary.mdtest#mysection
|
||||
/// Requires onWikiLink callback to be non-null.
|
||||
/// [](wiki:id) Clickable image/animation/video link.
|
||||
/// Wrapping any media line in [...](...) makes the
|
||||
/// whole media widget navigate on left-click.
|
||||
/// Right-click still shows the alt-text popup.
|
||||
/// <a id="name" /> Invisible named anchor. Use <a id="name" /> or
|
||||
/// <a name="name" /> on its own line or embedded in a
|
||||
/// heading (e.g. "## <a id="top" />Heading").
|
||||
/// Anchor names should be lowercase without spaces.
|
||||
/// If @p anchors is non-null the map is populated with
|
||||
/// name → content Y-offset (pixels) pairs so callers
|
||||
/// can scroll a CViewport to a specific section.
|
||||
/// {VCMI color tags} Passed through unchanged to all text labels.
|
||||
/// regular paragraphs Auto-wrapped CMultiLineLabel; blank line = gap.
|
||||
///
|
||||
@@ -68,10 +73,13 @@ class CViewport;
|
||||
/// @param blueStyle True → blue wiki theme; false → brown theme.
|
||||
/// @param onWikiLink Optional callback invoked when a wiki link is clicked.
|
||||
/// Argument is the raw URI string, e.g. "wiki:creature/imp".
|
||||
/// @param anchors Optional output map populated with anchor name → content
|
||||
/// Y-offset (px) pairs for every <a id="..."/> tag found.
|
||||
/// @return Flat list of created widgets (parented to viewport).
|
||||
std::vector<std::shared_ptr<CIntObject>> buildMarkdownContent(
|
||||
CViewport & viewport,
|
||||
const std::string & markdownText,
|
||||
int viewportWidth,
|
||||
bool blueStyle,
|
||||
std::function<void(const std::string &)> onWikiLink = nullptr);
|
||||
std::function<void(const std::string &)> onWikiLink = nullptr,
|
||||
std::map<std::string, int> * anchors = nullptr);
|
||||
|
||||
@@ -989,6 +989,14 @@ void WikiWindow::updateContent()
|
||||
const std::string & entryName = currentDisplayedEntries[activeElementIndex].identifier;
|
||||
if(entryName != currentGlossaryEntryName)
|
||||
rebuildGlossaryViewport(entryName);
|
||||
else if(!pendingAnchor.empty())
|
||||
{
|
||||
// Same page, just scroll to the anchor without a full rebuild.
|
||||
const auto anchorIt = glossaryAnchorMap.find(pendingAnchor);
|
||||
if(anchorIt != glossaryAnchorMap.end())
|
||||
glossaryContentView->scrollToY(anchorIt->second);
|
||||
pendingAnchor.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(activeCategoryIndex < 0 || activeElementIndex < 0
|
||||
@@ -1285,6 +1293,7 @@ void WikiWindow::rebuildGlossaryViewport(const std::string & entryName)
|
||||
const std::string markdownText = "# " + it->name + "\n\n" + it->description;
|
||||
|
||||
const bool isBlue = (style == Style::BLUE);
|
||||
glossaryAnchorMap.clear();
|
||||
{
|
||||
// Build a link callback that parses "wiki:category/id" and navigates.
|
||||
auto linkCb = [this](const std::string & target)
|
||||
@@ -1296,7 +1305,10 @@ void WikiWindow::rebuildGlossaryViewport(const std::string & entryName)
|
||||
const auto slash = rest.find('/');
|
||||
if(slash == std::string::npos) return;
|
||||
const std::string catStr = rest.substr(0, slash);
|
||||
const std::string id = rest.substr(slash + 1);
|
||||
const std::string idFull = rest.substr(slash + 1);
|
||||
const auto hashPos = idFull.find('#');
|
||||
const std::string id = (hashPos == std::string::npos) ? idFull : idFull.substr(0, hashPos);
|
||||
const std::string anchor = (hashPos == std::string::npos) ? std::string{} : idFull.substr(hashPos + 1);
|
||||
WikiCategory cat = WikiCategory::GLOSSARY;
|
||||
if (catStr == "glossary") cat = WikiCategory::GLOSSARY;
|
||||
else if(catStr == "town") cat = WikiCategory::TOWN;
|
||||
@@ -1307,15 +1319,24 @@ void WikiWindow::rebuildGlossaryViewport(const std::string & entryName)
|
||||
else if(catStr == "skill") cat = WikiCategory::SKILL;
|
||||
else if(catStr == "terrain") cat = WikiCategory::TERRAIN;
|
||||
else if(catStr == "mod") cat = WikiCategory::MOD;
|
||||
navigateTo(WikiEntryKey{cat, id});
|
||||
navigateTo(WikiEntryKey{cat, id, anchor});
|
||||
};
|
||||
auto moreWidgets = buildMarkdownContent(*glossaryContentView, markdownText,
|
||||
VP_W - CViewport::SLIDER_W, isBlue, linkCb);
|
||||
VP_W - CViewport::SLIDER_W, isBlue, linkCb, &glossaryAnchorMap);
|
||||
glossaryContentWidgets.insert(
|
||||
glossaryContentWidgets.end(), moreWidgets.begin(), moreWidgets.end());
|
||||
}
|
||||
glossaryContentView->fitContentSize();
|
||||
|
||||
// Scroll to the requested anchor if one was set before this rebuild.
|
||||
if(!pendingAnchor.empty())
|
||||
{
|
||||
const auto anchorIt = glossaryAnchorMap.find(pendingAnchor);
|
||||
if(anchorIt != glossaryAnchorMap.end())
|
||||
glossaryContentView->scrollToY(anchorIt->second);
|
||||
pendingAnchor.clear();
|
||||
}
|
||||
|
||||
applyScrollBounds();
|
||||
ENGINE->windows().totalRedraw();
|
||||
}
|
||||
@@ -1378,6 +1399,9 @@ void WikiWindow::navigateTo(const WikiEntryKey & key)
|
||||
if(catIdx < 0 || catIdx >= (int)categoryNames.size())
|
||||
return;
|
||||
|
||||
// Store the anchor to be consumed by rebuildGlossaryViewport().
|
||||
pendingAnchor = key.anchor;
|
||||
|
||||
// Deselect old highlighting before changing category/element
|
||||
if(categoryList && activeCategoryIndex >= 0)
|
||||
{
|
||||
|
||||
@@ -105,6 +105,9 @@ struct WikiEntryKey
|
||||
{
|
||||
WikiCategory category; ///< Which category tab to open
|
||||
std::string entryName; ///< Entity identifier / JSON key (used for lookup, not a translated string)
|
||||
/// Optional anchor to scroll to after the entry is displayed.
|
||||
/// Corresponds to the id/name attribute of an <a id="name" /> tag in the entry's Markdown.
|
||||
std::string anchor = {};
|
||||
};
|
||||
|
||||
/// In-game Glossary / Wiki - 800x600 stub window
|
||||
@@ -179,6 +182,8 @@ private:
|
||||
std::shared_ptr<CViewport> glossaryContentView; ///< scrollable viewport for Glossary (markdown renderer)
|
||||
std::vector<std::shared_ptr<CIntObject>> glossaryContentWidgets;
|
||||
std::string currentGlossaryEntryName; ///< identifier of the Glossary entry currently shown
|
||||
std::map<std::string, int> glossaryAnchorMap; ///< anchor name -> Y offset for the current glossary entry
|
||||
std::string pendingAnchor; ///< anchor to scroll to on the next rebuildGlossaryViewport() call
|
||||
|
||||
// --- navigation history -----------------------------------------------
|
||||
std::vector<WikiEntryKey> navHistory; ///< back-navigation stack
|
||||
|
||||
@@ -6,8 +6,8 @@ VCMI's in-game Wiki window has a **Glossary** category that shows free-form ency
|
||||
|
||||
Glossary entries are declared in
|
||||
|
||||
```
|
||||
<mod_root>/config/wikiGlossary.json
|
||||
```text
|
||||
<mod_root>/Content/config/wikiGlossary.json
|
||||
```
|
||||
|
||||
Each entry references two translation keys that must be present in the mod's translation file (e.g. `config/translations/english.json`).
|
||||
@@ -28,7 +28,7 @@ Each entry references two translation keys that must be present in the mod's tra
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
|---|---|---|
|
||||
| `name` | yes | Translation key for the list title shown in the left panel. |
|
||||
| `description` | yes | Translation key for the full article body shown on the right. The value supports the Markdown syntax described below. |
|
||||
|
||||
@@ -42,7 +42,7 @@ The description value is a JSON string. Use `\n` for newlines. The renderer su
|
||||
|
||||
### Headings
|
||||
|
||||
```
|
||||
```text
|
||||
# Heading level 1
|
||||
## Heading level 2
|
||||
### Heading level 3
|
||||
@@ -52,7 +52,7 @@ Headings are left-aligned by default. Wrap a heading in alignment tags (see bel
|
||||
|
||||
### Horizontal rule
|
||||
|
||||
```
|
||||
```text
|
||||
---
|
||||
```
|
||||
|
||||
@@ -62,28 +62,28 @@ Three or more `-`, `_`, or `*` characters on their own line.
|
||||
|
||||
A blank line (`\n\n`) ends the current paragraph and starts a new one. Alternatively, use the explicit `<p>` tag.
|
||||
|
||||
```
|
||||
```text
|
||||
First paragraph.\n\n<p>\nSecond paragraph (also starts after a p-tag).
|
||||
```
|
||||
|
||||
### Line breaks
|
||||
|
||||
| Syntax | Effect |
|
||||
|--------|--------|
|
||||
|---|---|
|
||||
| `<br>` inline inside a paragraph line | Inserts a line-break inside the rendered label without starting a new paragraph. |
|
||||
| `<br>` on its own line | Flushes the current paragraph to screen without adding extra vertical space. |
|
||||
|
||||
### Lists
|
||||
|
||||
**Unordered** – begin a line with `- ` or `* `:
|
||||
**Unordered** – begin a line with `-` or `*` followed by a space:
|
||||
|
||||
```
|
||||
```text
|
||||
- First item\n- Second item
|
||||
```
|
||||
|
||||
**Ordered** – begin a line with `N. ` (any integer):
|
||||
**Ordered** – begin a line with an integer, a period, and a space (e.g. `1.`):
|
||||
|
||||
```
|
||||
```text
|
||||
1. First step\n2. Second step
|
||||
```
|
||||
|
||||
@@ -92,7 +92,7 @@ First paragraph.\n\n<p>\nSecond paragraph (also starts after a p-tag).
|
||||
Wrap one or more block elements (headings, images, animations, videos) between an opening and a matching closing tag to control their alignment:
|
||||
|
||||
| Opening tag | Closing tag | Effect |
|
||||
|-------------|-------------|--------|
|
||||
|---|---|---|
|
||||
| `<left>` | `</left>` | Align to the left. |
|
||||
| `<center>` | `</center>` | Align to the centre. |
|
||||
| `<right>` | `</right>` | Align to the right. |
|
||||
@@ -103,14 +103,14 @@ Headings default to **left**; images, animations, and videos default to **left**
|
||||
|
||||
### Images
|
||||
|
||||
```
|
||||
```text
|
||||

|
||||
```
|
||||
|
||||
The file extension determines how the resource is loaded.
|
||||
|
||||
| Extension | Rendering |
|
||||
|-----------|-----------|
|
||||
|---|---|
|
||||
| `.png` `.pcx` `.bmp` (or any non-animation image) | Static image. Downscaled to fit the viewport width if wider. |
|
||||
| `.def` `.json` (no `#N` suffix) | Animation; all frames played as a looping ~6 fps animation. |
|
||||
| `.def` `.json#N` | Single static frame `N` from the animation. |
|
||||
@@ -122,7 +122,7 @@ If the `alt text` field is non-empty, right-clicking the image, animation, or vi
|
||||
|
||||
#### Examples
|
||||
|
||||
```
|
||||
```text
|
||||
Static image:
|
||||

|
||||
|
||||
@@ -148,7 +148,7 @@ Animation with right-click tooltip:
|
||||
|
||||
GFM-style pipe tables are supported. The first row is automatically rendered as a header (yellow, dark background). The second row **must** be a separator row (`|---|---|`).
|
||||
|
||||
```
|
||||
```text
|
||||
| Column A | Column B |
|
||||
|----------|----------|
|
||||
| Cell text | More text |
|
||||
@@ -156,7 +156,7 @@ GFM-style pipe tables are supported. The first row is automatically rendered as
|
||||
|
||||
Cell content may be any media syntax:
|
||||
|
||||
```
|
||||
```text
|
||||
| Creature | Icon |
|
||||
|----------|------|
|
||||
| Frame 0 |  |
|
||||
@@ -169,7 +169,7 @@ Column widths are distributed equally. Text cells wrap automatically.
|
||||
|
||||
All text (paragraphs, lists, table cells, headings) passes through the VCMI label renderer, so `{highlighted text}` colour tags work everywhere:
|
||||
|
||||
```
|
||||
```text
|
||||
The {Fire Wall} spell deals {direct damage}.
|
||||
```
|
||||
|
||||
@@ -179,7 +179,7 @@ Wiki links navigate to another entry when clicked. They are shown underlined in
|
||||
|
||||
#### Text link
|
||||
|
||||
```
|
||||
```text
|
||||
[display text](wiki:category/identifier)
|
||||
```
|
||||
|
||||
@@ -189,7 +189,7 @@ Placed on its own line or inline within a paragraph.
|
||||
|
||||
An image, animation, or video wrapped in a link navigates on left-click:
|
||||
|
||||
```
|
||||
```text
|
||||
[](wiki:category/identifier)
|
||||
```
|
||||
|
||||
@@ -198,7 +198,7 @@ Right-clicking a linked image still shows the alt-text tooltip as normal.
|
||||
#### Categories and identifiers
|
||||
|
||||
| Category string | Content |
|
||||
|-----------------|---------|
|
||||
|---|---|
|
||||
| `glossary` | Manual glossary entries |
|
||||
| `creature` | Creature list |
|
||||
| `spell` | Spell list |
|
||||
@@ -211,13 +211,13 @@ Right-clicking a linked image still shows the alt-text tooltip as normal.
|
||||
|
||||
**Glossary identifier** – the translation key of the entry's `name` field, with the trailing `.name` stripped:
|
||||
|
||||
```
|
||||
```text
|
||||
"name": "mymod.wiki.myfeature.name" → wiki:glossary/mymod.wiki.myfeature
|
||||
```
|
||||
|
||||
**Game-entity identifier** – the JSON key of the entity as returned by `getJsonKey()`. For core content this is typically just the unscoped name:
|
||||
|
||||
```
|
||||
```text
|
||||
wiki:creature/imp (matches "core:imp")
|
||||
wiki:spell/fireball
|
||||
wiki:skill/eagleEye
|
||||
@@ -225,6 +225,41 @@ wiki:skill/eagleEye
|
||||
|
||||
Both the scoped form (`core:imp`) and the unscoped form (`imp`) are accepted.
|
||||
|
||||
### Anchors
|
||||
|
||||
Invisible anchors mark a position inside a page so that a link can jump directly to that position. They are never rendered; only their Y offset is recorded.
|
||||
|
||||
#### Standalone anchor
|
||||
|
||||
Place the tag on its own line (nothing else on that line):
|
||||
|
||||
```text
|
||||
<a id="my-anchor" />
|
||||
```
|
||||
|
||||
Both `id` and `name` attributes are accepted. Anchor names are case-sensitive; lowercase letters, digits, and hyphens are recommended.
|
||||
|
||||
#### Anchor embedded in a heading
|
||||
|
||||
Prefix or suffix the heading line with an anchor tag on the same Markdown line:
|
||||
|
||||
```text
|
||||
## <a id="my-section" />Section Title
|
||||
## Section Title<a id="my-section" />
|
||||
```
|
||||
|
||||
The anchor tag is stripped before the heading is rendered; the visible heading text is not affected.
|
||||
|
||||
#### Linking to an anchor
|
||||
|
||||
Append `#anchorname` to the entry identifier in any wiki link:
|
||||
|
||||
```text
|
||||
[Jump to my section](wiki:glossary/mymod.wiki.mypage#my-section)
|
||||
```
|
||||
|
||||
Navigation first loads the target entry, then scrolls the page to the anchor position.
|
||||
|
||||
---
|
||||
|
||||
## Minimal example
|
||||
|
||||
Reference in New Issue
Block a user