1
0
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:
Laserlicht
2026-05-01 19:18:25 +02:00
parent 9805b2e5b7
commit 3584aeb5cd
7 changed files with 173 additions and 33 deletions
+6
View File
@@ -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;
+5
View File
@@ -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);
};
+58 -1
View File
@@ -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;
+14 -6
View File
@@ -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.
/// [![alt](media)](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);
+27 -3
View File
@@ -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)
{
+5
View File
@@ -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
+58 -23
View File
@@ -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
![alt text](filename.ext)
```
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:
![Background](DIBOXBCK.PCX)
@@ -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 | ![f0](CPRSMALL.DEF#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
[![alt text](media.def)](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