Compare commits

..

8 Commits

Author SHA1 Message Date
dependabot[bot] 0936afc2f9 chore(deps-dev): Bump typescript-eslint from 8.62.1 to 8.64.0 (#2013)
Bumps
[typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint)
from 8.62.1 to 8.64.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases">typescript-eslint's
releases</a>.</em></p>
<blockquote>
<h2>v8.64.0</h2>
<h2>8.64.0 (2026-07-13)</h2>
<h3>🚀 Features</h3>
<ul>
<li>support parsing <code>import defer</code> (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12513">#12513</a>)</li>
<li><strong>eslint-plugin:</strong> [no-loop-func] support
<code>using</code> / <code>await using</code> declarations and deprecate
the rule (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12500">#12500</a>)</li>
<li><strong>typescript-estree:</strong> throw for invalid definite
assignment in class properties (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12543">#12543</a>)</li>
</ul>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [require-array-sort-compare] handle
constrained arrays (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12512">#12512</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Evyatar Daud <a
href="https://github.com/StyleShit"><code>@​StyleShit</code></a></li>
<li>송재욱</li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.64.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
<h2>v8.63.0</h2>
<h2>8.63.0 (2026-07-06)</h2>
<h3>🚀 Features</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-misused-promises] detect async
usage of a sync dispose usage (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12426">#12426</a>)</li>
</ul>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [method-signature-style] suggest
converting readonly function properties instead of emitting invalid
syntax (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12447">#12447</a>,
<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/issues/12446">#12446</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
handle optional-chained calls to overloaded functions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12491">#12491</a>,
<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/issues/12485">#12485</a>)</li>
<li><strong>eslint-plugin:</strong> [no-base-to-string] don't flag a
shadowed String() call (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12492">#12492</a>)</li>
<li><strong>scope-manager:</strong> export ClassStaticBlockScope (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12460">#12460</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Evyatar Daud <a
href="https://github.com/StyleShit"><code>@​StyleShit</code></a></li>
<li>Kristjan <a
href="https://github.com/KristjanTammekivi"><code>@​KristjanTammekivi</code></a></li>
<li>Michael Naumov <a
href="https://github.com/mnaoumov"><code>@​mnaoumov</code></a></li>
<li>Serhii Leniv <a
href="https://github.com/Serhii-Leniv"><code>@​Serhii-Leniv</code></a></li>
<li>송재욱</li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.63.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md">typescript-eslint's
changelog</a>.</em></p>
<blockquote>
<h2>8.64.0 (2026-07-13)</h2>
<p>This was a version bump only for typescript-eslint to align it with
other projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.64.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
<h2>8.63.0 (2026-07-06)</h2>
<p>This was a version bump only for typescript-eslint to align it with
other projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.63.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/typescript-eslint/typescript-eslint/commit/414d9abbf66f77796ab12ec7e75b07722e592832"><code>414d9ab</code></a>
chore(release): publish 8.64.0</li>
<li><a
href="https://github.com/typescript-eslint/typescript-eslint/commit/290cf6cdcc5ffb00c5b8f3e1e0e9f2fd8cc96374"><code>290cf6c</code></a>
chore(release): publish 8.63.0</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.64.0/packages/typescript-eslint">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=typescript-eslint&package-manager=npm_and_yarn&previous-version=8.62.1&new-version=8.64.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-17 13:51:37 +00:00
dependabot[bot] b664caa13e chore(deps-dev): Bump postcss from 8.5.17 to 8.5.19 (#2011)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.17 to
8.5.19.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/releases">postcss's
releases</a>.</em></p>
<blockquote>
<h2>8.5.19</h2>
<ul>
<li>Fixed cleaning <code>before</code> for new nodes inserted to
<code>Root</code> (by <a
href="https://github.com/MahinAnowar"><code>@​MahinAnowar</code></a>).</li>
</ul>
<h2>8.5.18</h2>
<ul>
<li>Restricted loading previous source maps file to the
<code>opts.from</code> folder for security reasons (use <code>unsafeMap:
true</code> to disable the check).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/blob/main/CHANGELOG.md">postcss's
changelog</a>.</em></p>
<blockquote>
<h2>8.5.19</h2>
<ul>
<li>Fixed cleaning <code>before</code> for new nodes inserted to
<code>Root</code> (by <a
href="https://github.com/MahinAnowar"><code>@​MahinAnowar</code></a>).</li>
</ul>
<h2>8.5.18</h2>
<ul>
<li>Restricted loading previous source maps file to the
<code>opts.from</code> folder for security reasons (use <code>unsafeMap:
true</code> to disable the check).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/postcss/postcss/commit/9543b22769bef5bcd47600fbca752204c106cda8"><code>9543b22</code></a>
Release 8.5.19 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/3d13bf9360652922de0535f6257e1648187363f0"><code>3d13bf9</code></a>
Fix CI on Windows too</li>
<li><a
href="https://github.com/postcss/postcss/commit/00d0dd2322162f6083d507ea6954685e1c92f165"><code>00d0dd2</code></a>
Keep explicitly set raws.before when inserting nodes into root (<a
href="https://redirect.github.com/postcss/postcss/issues/2111">#2111</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/7a05b33e7a15d6f80d90098784170ad3dca39180"><code>7a05b33</code></a>
Temporary fix CI</li>
<li><a
href="https://github.com/postcss/postcss/commit/4c0d194c136fd374495d0993c890d794cab65b81"><code>4c0d194</code></a>
Release 8.5.18 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/92b4e7891ec7b811821d01acc8aa0f010caf41e2"><code>92b4e78</code></a>
Update dependencies</li>
<li><a
href="https://github.com/postcss/postcss/commit/95663d3eb7ba26f4854dd19d3b4f4425760cf56c"><code>95663d3</code></a>
Limit where source map can be loaded for security reasons</li>
<li>See full diff in <a
href="https://github.com/postcss/postcss/compare/8.5.17...8.5.19">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=postcss&package-manager=npm_and_yarn&previous-version=8.5.17&new-version=8.5.19)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 13:51:58 +00:00
dependabot[bot] d90d7b743b chore(deps): Bump actions/setup-node from 6.4.0 to 7.0.0 (#2012)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from
6.4.0 to 7.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-node/releases">actions/setup-node's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements:</h3>
<ul>
<li>Add cache-primary-key and cache-matched-key as outputs by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1577">actions/setup-node#1577</a></li>
<li>Migrate to ESM and upgrade dependencies by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1574">actions/setup-node#1574</a></li>
</ul>
<h3>Bug fixes:</h3>
<ul>
<li>Remove dummy NODE_AUTH_TOKEN export by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1558">actions/setup-node#1558</a></li>
<li>Only use <code>mirrorToken</code> in <code>getManifest</code> if
it's provided by <a
href="https://github.com/deiga"><code>@​deiga</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1548">actions/setup-node#1548</a></li>
</ul>
<h3>Documentation updates:</h3>
<ul>
<li>Add documentation for publishing to npm with Trusted Publisher
(OIDC) by <a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1536">actions/setup-node#1536</a></li>
<li>docs: Update restore-only cache documentation by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1550">actions/setup-node#1550</a></li>
<li>docs: Update caching recommendations to mitigate cache poisoning
risks by <a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1567">actions/setup-node#1567</a></li>
</ul>
<h3>Dependency update:</h3>
<ul>
<li>Upgrade <code>@​actions/cache</code> to 5.1.0, log cache write
denied by <a
href="https://github.com/jasongin"><code>@​jasongin</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1569">actions/setup-node#1569</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1536">actions/setup-node#1536</a></li>
<li><a href="https://github.com/deiga"><code>@​deiga</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1548">actions/setup-node#1548</a></li>
<li><a href="https://github.com/jasongin"><code>@​jasongin</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1569">actions/setup-node#1569</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6...v7.0.0">https://github.com/actions/setup-node/compare/v6...v7.0.0</a></p>
<h2>v6.5.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update <code>@​actions/cache</code> to 5.1.0 and add security
overrides for undici and fast-xml-parser by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1579">actions/setup-node#1579</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0">https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-node/commit/820762786026740c76f36085b0efc47a31fe5020"><code>8207627</code></a>
Migrate to ESM and upgrade dependencies (<a
href="https://redirect.github.com/actions/setup-node/issues/1574">#1574</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/04be95cf3511ea51ebf9f224ddfb99cc7ab87cd4"><code>04be95c</code></a>
Add cache-primary-key and cache-matched-key as outputs (<a
href="https://redirect.github.com/actions/setup-node/issues/1577">#1577</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/7c2c68d20d402ed6a201ada70a81341941093140"><code>7c2c68d</code></a>
docs: Update caching recommendations to mitigate cache poisoning risks
(<a
href="https://redirect.github.com/actions/setup-node/issues/1567">#1567</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/6a61c0375d66246de94630495909f12cf8dac84d"><code>6a61c03</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/setup-node/issues/1569">#1569</a>
from jasongin/update-actions-cache-5.1.0</li>
<li><a
href="https://github.com/actions/setup-node/commit/30eb73b41ded577900c1ebf968ef95cdf8f7434f"><code>30eb73b</code></a>
Resolve high-severity audit issues</li>
<li><a
href="https://github.com/actions/setup-node/commit/4e1a87a501d0302f99e30e2748568adcb388d09f"><code>4e1a87a</code></a>
Update dist</li>
<li><a
href="https://github.com/actions/setup-node/commit/360237f0c01778d0c17291f75c56d6feae4f7574"><code>360237f</code></a>
Strict equality</li>
<li><a
href="https://github.com/actions/setup-node/commit/4f8aac5beb2f0854bc79651567a18c67eb0b9de3"><code>4f8aac5</code></a>
Bump <code>@​actions/cache</code> to 5.1.0, log cache write denied</li>
<li><a
href="https://github.com/actions/setup-node/commit/f4a67bbeca970f103397d3d2b9462cf787cd2980"><code>f4a67bb</code></a>
Only use <code>mirrorToken</code> in <code>getManifest</code> if it's
provided (<a
href="https://redirect.github.com/actions/setup-node/issues/1548">#1548</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/0355742c943ddb13ca8a6b700f824231caa91e75"><code>0355742</code></a>
Remove dummy NODE_AUTH_TOKEN export (<a
href="https://redirect.github.com/actions/setup-node/issues/1558">#1558</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-node&package-manager=github_actions&previous-version=6.4.0&new-version=7.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 13:51:07 +00:00
dependabot[bot] 0152a02858 chore(deps-dev): Bump postcss from 8.5.16 to 8.5.17 (#2009)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.16 to
8.5.17.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/releases">postcss's
releases</a>.</em></p>
<blockquote>
<h2>8.5.17</h2>
<ul>
<li>Fixed <code>Maximum call stack size exceeded</code> error.</li>
<li>Fixed Prototype hijacking for <code>postcss.fromJSON()</code>.</li>
<li>Fixed <code>Input#origin()</code> for unmapped end position (by <a
href="https://github.com/chatman-media"><code>@​chatman-media</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/blob/main/CHANGELOG.md">postcss's
changelog</a>.</em></p>
<blockquote>
<h2>8.5.17</h2>
<ul>
<li>Fixed <code>Maximum call stack size exceeded</code> error.</li>
<li>Fixed Prototype hijacking for <code>postcss.fromJSON()</code>.</li>
<li>Fixed <code>Input#origin()</code> for unmapped end position (by <a
href="https://github.com/chatman-media"><code>@​chatman-media</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/postcss/postcss/commit/74e25ae9f4efaa56a41a449064a655d7da78072c"><code>74e25ae</code></a>
Release 8.5.17 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/d1518afd5a88f42728b30b87f8917210f363f9f1"><code>d1518af</code></a>
Fix Maximum call stack size exceeded error</li>
<li><a
href="https://github.com/postcss/postcss/commit/2421312ffea96ba77b35ce24a1b2d9c2e22b5e83"><code>2421312</code></a>
Fix linter</li>
<li><a
href="https://github.com/postcss/postcss/commit/a50352c583df991710f92ccac25b36304695161a"><code>a50352c</code></a>
Fix CI</li>
<li><a
href="https://github.com/postcss/postcss/commit/33948f0969bb858acdd52c9692e3a785a3ed0a73"><code>33948f0</code></a>
Prevent prototype hijacking in fromJSON</li>
<li><a
href="https://github.com/postcss/postcss/commit/2131909351161cd2c5fc2be58b14919a873ea824"><code>2131909</code></a>
Update dependencies</li>
<li><a
href="https://github.com/postcss/postcss/commit/93440abcca92793b31c5d1fdf5f2da7b58b27599"><code>93440ab</code></a>
Fix non-closed <code>\&lt;div align=&quot;center&quot;&gt;</code> in
README (<a
href="https://redirect.github.com/postcss/postcss/issues/2110">#2110</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/1bf9076867e70f5a01776aebebee5aacbf9771ce"><code>1bf9076</code></a>
Remove old sponsors</li>
<li><a
href="https://github.com/postcss/postcss/commit/2d44be0923873ea6af2c3a1a191148e2ce9d733f"><code>2d44be0</code></a>
docs: fix invalid JS in writing-a-plugin examples (<a
href="https://redirect.github.com/postcss/postcss/issues/2107">#2107</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/3298727755a676beced3cbe67015d42e70396e4f"><code>3298727</code></a>
Fix <code>Input#origin()</code> mixing null and undefined for unmapped
end position (<a
href="https://redirect.github.com/postcss/postcss/issues/2106">#2106</a>)</li>
<li>See full diff in <a
href="https://github.com/postcss/postcss/compare/8.5.16...8.5.17">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=postcss&package-manager=npm_and_yarn&previous-version=8.5.16&new-version=8.5.17)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 13:50:43 +00:00
dependabot[bot] 0b076e3896 chore(deps-dev): Bump eslint from 10.6.0 to 10.7.0 (#2007)
Bumps [eslint](https://github.com/eslint/eslint) from 10.6.0 to 10.7.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/eslint/eslint/releases">eslint's
releases</a>.</em></p>
<blockquote>
<h2>v10.7.0</h2>
<h2>Features</h2>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/cf2a9bf2d982642f760370c15f1196f4658f4e27"><code>cf2a9bf</code></a>
feat: add errorClassNames option to preserve-caught-error rule (<a
href="https://redirect.github.com/eslint/eslint/issues/21032">#21032</a>)
(sethamus)</li>
<li><a
href="https://github.com/eslint/eslint/commit/f8b873aae53610b80b5c1005606716e05ed3b91f"><code>f8b873a</code></a>
feat: max-nested-callbacks option for constructor callbacks (<a
href="https://redirect.github.com/eslint/eslint/issues/21063">#21063</a>)
(fnx)</li>
<li><a
href="https://github.com/eslint/eslint/commit/557fde8bc633d26ea4fe287d4483f287e8e6080f"><code>557fde8</code></a>
feat: support computed <code>Number.parseInt</code> member access in
<code>radix</code> rule (<a
href="https://redirect.github.com/eslint/eslint/issues/21041">#21041</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/0b4a73b65808f5a8fd192f15db2bf5e47002b9f7"><code>0b4a73b</code></a>
feat: add suggestions to no-compare-neg-zero (<a
href="https://redirect.github.com/eslint/eslint/issues/21034">#21034</a>)
(den$)</li>
<li><a
href="https://github.com/eslint/eslint/commit/96cdd427db5f26c6403fba92b0f7ae4556c595ec"><code>96cdd42</code></a>
feat: report invalid signed numeric radix values in <code>radix</code>
rule (<a
href="https://redirect.github.com/eslint/eslint/issues/21030">#21030</a>)
(Pixel)</li>
</ul>
<h2>Bug Fixes</h2>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/3e7bf15e69e6d3a2c8832356bcc2e9903cc4eede"><code>3e7bf15</code></a>
fix: apply <code>ignoreClassesWithImplements</code> to class expressions
(<a
href="https://redirect.github.com/eslint/eslint/issues/21069">#21069</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/0d7d70cf013ec5bf1e4f5a0fcf7128bdf059b2d4"><code>0d7d70c</code></a>
fix: insert cause outside wrapping parens in preserve-caught-error (<a
href="https://redirect.github.com/eslint/eslint/issues/21062">#21062</a>)
(Mahin Anowar)</li>
<li><a
href="https://github.com/eslint/eslint/commit/75ec753226010867270787b412f3dae412e421e6"><code>75ec753</code></a>
fix: handle static template literals in <code>eqeqeq</code> rule (<a
href="https://redirect.github.com/eslint/eslint/issues/21058">#21058</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/b717a22e2408389a2ad40af66543e46390669eb6"><code>b717a22</code></a>
fix: prevent <code>eqeqeq</code> null option from reporting non-equality
operators (<a
href="https://redirect.github.com/eslint/eslint/issues/21057">#21057</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/e35b05f1961dcd691611bd68b6ff8a87072d6f76"><code>e35b05f</code></a>
fix: avoid <code>no-invalid-regexp</code> false positive for shadowed
RegExp (<a
href="https://redirect.github.com/eslint/eslint/issues/21051">#21051</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/a3172b69c7db63ea0321355543e3f527c7d8b76a"><code>a3172b6</code></a>
fix: avoid <code>no-control-regex</code> false positive for shadowed
RegExp (<a
href="https://redirect.github.com/eslint/eslint/issues/21050">#21050</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/d1f637eca27e523d613991c6bea5b8726b810e4c"><code>d1f637e</code></a>
fix: parenthesize sequence expression operands in no-implicit-coercion
(<a
href="https://redirect.github.com/eslint/eslint/issues/21045">#21045</a>)
(spokodev)</li>
<li><a
href="https://github.com/eslint/eslint/commit/8859bafb018e2b23b5110d52e491b69b94ad890a"><code>8859baf</code></a>
fix: avoid prefer-numeric-literals false positive for shadowed globals
(<a
href="https://redirect.github.com/eslint/eslint/issues/21047">#21047</a>)
(한국)</li>
<li><a
href="https://github.com/eslint/eslint/commit/a9e5961050676ef29dba9649dfcd7233d21760c7"><code>a9e5961</code></a>
fix: use-isnan false positive on shadowed NaN/Number (<a
href="https://redirect.github.com/eslint/eslint/issues/20958">#20958</a>)
(sethamus)</li>
<li><a
href="https://github.com/eslint/eslint/commit/8a240a76108f30f2476e61a925e4d32d378cce5e"><code>8a240a7</code></a>
fix: avoid false positives in <code>radix</code> rule for spread
arguments (<a
href="https://redirect.github.com/eslint/eslint/issues/21044">#21044</a>)
(Pixel)</li>
</ul>
<h2>Documentation</h2>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/c30d80801ca561bdf65c6c3eba4ee57dced278cf"><code>c30d808</code></a>
docs: Update README (GitHub Actions Bot)</li>
<li><a
href="https://github.com/eslint/eslint/commit/5139800b23e77fdd1a76664e1b36f478942eae55"><code>5139800</code></a>
docs: document ESLint migration codemods in v9 and v10 guides (<a
href="https://redirect.github.com/eslint/eslint/issues/20980">#20980</a>)
(Alex Bit)</li>
<li><a
href="https://github.com/eslint/eslint/commit/04174cbfaac1f9555400b5332d69056e6aab9cc6"><code>04174cb</code></a>
docs: Update README (GitHub Actions Bot)</li>
<li><a
href="https://github.com/eslint/eslint/commit/026e1304080512bcddfd2af3557fff86474ff949"><code>026e130</code></a>
docs: update semver policy for bug fixes (<a
href="https://redirect.github.com/eslint/eslint/issues/21048">#21048</a>)
(Milos Djermanovic)</li>
<li><a
href="https://github.com/eslint/eslint/commit/9d42fefb0e05ecd0b256c2ebed479fc868862f0c"><code>9d42fef</code></a>
docs: Update README (GitHub Actions Bot)</li>
<li><a
href="https://github.com/eslint/eslint/commit/b23015955c8d6e6516076190730f538c86927f26"><code>b230159</code></a>
docs: Update README (GitHub Actions Bot)</li>
<li><a
href="https://github.com/eslint/eslint/commit/0129972ba122e95bece6040fda44d20f6799246a"><code>0129972</code></a>
docs: correct <code>**/.js</code> glob to <code>**/*.js</code> in config
files guide (<a
href="https://redirect.github.com/eslint/eslint/issues/21036">#21036</a>)
(EduardF1)</li>
</ul>
<h2>Chores</h2>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/948937986f2aa3cccb27a4c80694a85e5b0a5f64"><code>9489379</code></a>
chore: update dependency <code>@​eslint/eslintrc</code> to ^3.3.6 (<a
href="https://redirect.github.com/eslint/eslint/issues/21076">#21076</a>)
(renovate[bot])</li>
<li><a
href="https://github.com/eslint/eslint/commit/81a4774a928211bd0c01a06a1517736ece594522"><code>81a4774</code></a>
chore: updates for v9.39.5 release (Jenkins)</li>
<li><a
href="https://github.com/eslint/eslint/commit/983541491393323d17717683f6a2e6dbef4bc3f4"><code>9835414</code></a>
chore: enable <code>$ExpectType</code> annotations in all TypeScript
files (<a
href="https://redirect.github.com/eslint/eslint/issues/21071">#21071</a>)
(Francesco Trotta)</li>
<li><a
href="https://github.com/eslint/eslint/commit/72adf6bd214c142b38aabb4b8f6f908079c5301a"><code>72adf6b</code></a>
chore: restrict <code>markdownlint-cli2</code> updates in renovate (<a
href="https://redirect.github.com/eslint/eslint/issues/21067">#21067</a>)
(lumir)</li>
<li><a
href="https://github.com/eslint/eslint/commit/833ec10fd702644e94334edd3cd2aa313174a958"><code>833ec10</code></a>
chore: update dependency prettier to v3.9.4 (<a
href="https://redirect.github.com/eslint/eslint/issues/21061">#21061</a>)
(renovate[bot])</li>
<li><a
href="https://github.com/eslint/eslint/commit/7ea106ddcb6af7db8f36d62a28dbaba94d33598e"><code>7ea106d</code></a>
chore: update ecosystem plugins (<a
href="https://redirect.github.com/eslint/eslint/issues/21059">#21059</a>)
(ESLint Bot)</li>
<li><a
href="https://github.com/eslint/eslint/commit/8fb550e7c257fbdc4108fc8e63db43c1d914d472"><code>8fb550e</code></a>
chore: add prettier update commit to <code>.git-blame-ignore-revs</code>
(<a
href="https://redirect.github.com/eslint/eslint/issues/21056">#21056</a>)
(lumir)</li>
<li><a
href="https://github.com/eslint/eslint/commit/e4e11668ead6b950328c71e894563a13ef72bf84"><code>e4e1166</code></a>
chore: update dependency prettier to v3.9.1 (<a
href="https://redirect.github.com/eslint/eslint/issues/21055">#21055</a>)
(renovate[bot])</li>
<li><a
href="https://github.com/eslint/eslint/commit/0493f53f3fcec4ed69a1808dffa153dcff1380d3"><code>0493f53</code></a>
chore: update prettier to v3.9.0 (<a
href="https://redirect.github.com/eslint/eslint/issues/21054">#21054</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/1056a996c657b377dc16a58c28890d2940394408"><code>1056a99</code></a>
chore: update dependency prettier to v3.8.5 (<a
href="https://redirect.github.com/eslint/eslint/issues/21049">#21049</a>)
(renovate[bot])</li>
<li><a
href="https://github.com/eslint/eslint/commit/4d4155df4a21d711e8dd21a467de085e770bad35"><code>4d4155d</code></a>
ci: run ecosystem tests on pull requests (<a
href="https://redirect.github.com/eslint/eslint/issues/21027">#21027</a>)
(sethamus)</li>
<li><a
href="https://github.com/eslint/eslint/commit/993539fd45d3d01aad3fbc13963e2245fdf7ee0d"><code>993539f</code></a>
chore: update dependency <code>@​eslint/json</code> to ^2.0.1 (<a
href="https://redirect.github.com/eslint/eslint/issues/21042">#21042</a>)
(renovate[bot])</li>
<li><a
href="https://github.com/eslint/eslint/commit/53f8b69c15af7398c989071ee4788b88edd48bdd"><code>53f8b69</code></a>
test: add error locations to <code>no-constant-binary-expression</code>
(<a
href="https://redirect.github.com/eslint/eslint/issues/21039">#21039</a>)
(lumir)</li>
<li><a
href="https://github.com/eslint/eslint/commit/5ab71d52f9d860df04e626fcd819a36257f21f9e"><code>5ab71d5</code></a>
refactor: clean up radix rule internals (<a
href="https://redirect.github.com/eslint/eslint/issues/21015">#21015</a>)
(Pixel)</li>
<li><a
href="https://github.com/eslint/eslint/commit/a80a9a461478838930c5545f840473840b26b0e6"><code>a80a9a4</code></a>
chore: update ecosystem plugins (<a
href="https://redirect.github.com/eslint/eslint/issues/21035">#21035</a>)
(ESLint Bot)</li>
<li><a
href="https://github.com/eslint/eslint/commit/7c9a02933cf1c231237babb8a7a32150ab72e263"><code>7c9a029</code></a>
ci: add Node.js 26 to CI (<a
href="https://redirect.github.com/eslint/eslint/issues/20847">#20847</a>)
(lumir)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/eslint/eslint/commit/fabd99bb74eedcb950df51ec3280414ff1d68d38"><code>fabd99b</code></a>
10.7.0</li>
<li><a
href="https://github.com/eslint/eslint/commit/37c5e75949ff2ae1f0085f547e35ce2bdf9d524d"><code>37c5e75</code></a>
Build: changelog update for 10.7.0</li>
<li><a
href="https://github.com/eslint/eslint/commit/948937986f2aa3cccb27a4c80694a85e5b0a5f64"><code>9489379</code></a>
chore: update dependency <code>@​eslint/eslintrc</code> to ^3.3.6 (<a
href="https://redirect.github.com/eslint/eslint/issues/21076">#21076</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/81a4774a928211bd0c01a06a1517736ece594522"><code>81a4774</code></a>
chore: updates for v9.39.5 release</li>
<li><a
href="https://github.com/eslint/eslint/commit/3e7bf15e69e6d3a2c8832356bcc2e9903cc4eede"><code>3e7bf15</code></a>
fix: apply <code>ignoreClassesWithImplements</code> to class expressions
(<a
href="https://redirect.github.com/eslint/eslint/issues/21069">#21069</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/983541491393323d17717683f6a2e6dbef4bc3f4"><code>9835414</code></a>
chore: enable <code>$ExpectType</code> annotations in all TypeScript
files (<a
href="https://redirect.github.com/eslint/eslint/issues/21071">#21071</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/cf2a9bf2d982642f760370c15f1196f4658f4e27"><code>cf2a9bf</code></a>
feat: add errorClassNames option to preserve-caught-error rule (<a
href="https://redirect.github.com/eslint/eslint/issues/21032">#21032</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/c30d80801ca561bdf65c6c3eba4ee57dced278cf"><code>c30d808</code></a>
docs: Update README</li>
<li><a
href="https://github.com/eslint/eslint/commit/f8b873aae53610b80b5c1005606716e05ed3b91f"><code>f8b873a</code></a>
feat: max-nested-callbacks option for constructor callbacks (<a
href="https://redirect.github.com/eslint/eslint/issues/21063">#21063</a>)</li>
<li><a
href="https://github.com/eslint/eslint/commit/72adf6bd214c142b38aabb4b8f6f908079c5301a"><code>72adf6b</code></a>
chore: restrict <code>markdownlint-cli2</code> updates in renovate (<a
href="https://redirect.github.com/eslint/eslint/issues/21067">#21067</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/eslint/eslint/compare/v10.6.0...v10.7.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=eslint&package-manager=npm_and_yarn&previous-version=10.6.0&new-version=10.7.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 13:52:48 +00:00
Harel M cf54b6fdeb Update style-spec package to 26.1 (#2005)
## Launch Checklist

Update style-spec package to version 26.1
This should solve a problem related to expression validation.

 - [x] Briefly describe the changes in this PR.
 - [x] Link to related issues.
2026-07-14 14:14:28 +03:00
dependabot[bot] e5e09e846a chore(deps): Bump github/codeql-action/init from 4.36.3 to 4.37.0 (#2002)
Bumps
[github/codeql-action/init](https://github.com/github/codeql-action)
from 4.36.3 to 4.37.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.0</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.19.3 and earlier. These versions of
CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise
Server 3.15, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3837">#3837</a></li>
<li>Configurations for private registries that use Cloudsmith or GCP
OIDC are now accepted. <a
href="https://redirect.github.com/github/codeql-action/pull/3850">#3850</a></li>
<li>Best-effort connection tests for private registries now use
<code>GET</code> requests instead of <code>HEAD</code> for better
compatibility with various registry implementations. For NuGet feeds,
the test is now always performed against the service index. <a
href="https://redirect.github.com/github/codeql-action/pull/3853">#3853</a></li>
<li>Fixed a bug where two diagnostics produced within the same
millisecond could overwrite each other on disk, causing one of them to
be lost. <a
href="https://redirect.github.com/github/codeql-action/pull/3852">#3852</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/99df26d4f13ea111d4ec1a7dddef6063f76b97e9"><code>99df26d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3996">#3996</a>
from github/update-v4.37.0-c7c896d71</li>
<li><a
href="https://github.com/github/codeql-action/commit/31c27074fda95256cda077009907f8a6022dd7c0"><code>31c2707</code></a>
Add changenote for <a
href="https://redirect.github.com/github/codeql-action/issues/3973">#3973</a></li>
<li><a
href="https://github.com/github/codeql-action/commit/72df2181aac054d1f4b44264399d2aac12cf11c6"><code>72df218</code></a>
Update changelog for v4.37.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/c7c896d71b3055d36f2aff93b16bcc6c69923b91"><code>c7c896d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3995">#3995</a>
from github/update-bundle/codeql-bundle-v2.26.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/3f34ff0ea3f5153c96071437b7cbf71ea3757146"><code>3f34ff0</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/43bec09f1dc368b430cab4b5d69799bc904079d1"><code>43bec09</code></a>
Update default bundle to codeql-bundle-v2.26.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/f58f0d11ebf5dedd870fab2f999275f7602cfa46"><code>f58f0d1</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3973">#3973</a>
from github/mbg/repo-props/config-file-shorthands</li>
<li><a
href="https://github.com/github/codeql-action/commit/7dc37cbb5b3e37f0e1cd1f18b61e0ea849898fb8"><code>7dc37cb</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/repo-props/config-file-sh...</li>
<li><a
href="https://github.com/github/codeql-action/commit/8e22350a7e28c34c82a5a499fc241923301c2c4f"><code>8e22350</code></a>
Thread <code>ActionState</code> to <code>initConfig</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/69c9e8c7d918cf2fee13b8b72fdde15883ff155b"><code>69c9e8c</code></a>
Mark some <code>status-report</code> imports as <code>type</code>-only
to avoid circular dependencies</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/54f647b7e1bb85c95cddabcd46b0c578ec92bc1a...99df26d4f13ea111d4ec1a7dddef6063f76b97e9">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action/init&package-manager=github_actions&previous-version=4.36.3&new-version=4.37.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 13:53:39 +00:00
dependabot[bot] 43bb23801c chore(deps): Bump i18next from 26.3.4 to 26.3.6 (#2001)
Bumps [i18next](https://github.com/i18next/i18next) from 26.3.4 to
26.3.6.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/i18next/i18next/releases">i18next's
releases</a>.</em></p>
<blockquote>
<h2>v26.3.6</h2>
<ul>
<li>fix: allow TypeScript 7 in the optional <code>typescript</code> peer
dependency range (<code>^5 || ^6 || ^7</code>). With
<code>typescript@7.0.2</code> in a project, <code>npm install</code>
failed with an <code>ERESOLVE</code> peer conflict. The published types
are TS7-compatible as-is: every <code>test/typescript</code> suite
produces identical results under 6.0 and 7.0.2. Reported in <a
href="https://redirect.github.com/i18next/react-i18next/issues/1927">react-i18next#1927</a>,
thanks <a
href="https://github.com/andikapradanaarif"><code>@​andikapradanaarif</code></a>.</li>
</ul>
<h2>v26.3.5</h2>
<ul>
<li>fix: <code>$t()</code> nesting options blocks that span multiple
lines are now parsed. <code>nest()</code> decided where the nested key
ends by testing <code>match[1]</code> with <code>/{.*}/</code>, whose
dot does not cross line breaks — so a <code>$t(key, { ... })</code>
options object containing a newline was treated as having no options,
mis-split as formatters, and the nested lookup ran without its options
(placeholders stayed unresolved). The nesting regexp itself already
matches newlines inside <code>$t(...)</code>; adding the <code>s</code>
(dotAll) flag makes multiline options behave like the single-line form.
Thanks <a href="https://github.com/spokodev"><code>@​spokodev</code></a>
(<a
href="https://redirect.github.com/i18next/i18next/pull/2440">#2440</a>).</li>
<li>fix: <code>getUsedParamsDetails</code> (the <code>returnDetails:
true</code> path) no longer mutates the passed <code>replace</code>
object. It wrote <code>count</code> straight onto
<code>options.replace</code> so the returned <code>usedParams</code>
would include it — a caller reusing one <code>replace</code> object
across <code>t()</code> calls then carried a stale <code>count</code>
into later interpolations (e.g. a previous call's <code>count: 5</code>
rendered instead of the current call's value). The details are now built
from a copy; <code>usedParams</code> still includes <code>count</code>.
Thanks <a href="https://github.com/spokodev"><code>@​spokodev</code></a>
(<a
href="https://redirect.github.com/i18next/i18next/pull/2441">#2441</a>).</li>
<li>fix: with the default <code>skipOnVariables: true</code> +
<code>escapeValue: true</code>, a <code>{{placeholder}}</code> carried
inside an interpolated value now stays literal even when the value
contains escapable characters. The skip logic advanced the regex
<code>lastIndex</code> by the raw value length, but the escaped text
written into the string is longer, so <code>lastIndex</code> landed
inside the inserted value and a trailing <code>{{placeholder}}</code> in
it got interpolated — leaking another in-scope variable that should have
stayed literal (values without escapable characters were already skipped
correctly). The advance now uses the escaped length that is actually
written, and the regex-safe <code>$</code>-doubling is applied only at
the <code>String.replace</code> call so it can't distort the length
arithmetic. Thanks <a
href="https://github.com/spokodev"><code>@​spokodev</code></a> (<a
href="https://redirect.github.com/i18next/i18next/pull/2442">#2442</a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/i18next/i18next/blob/master/CHANGELOG.md">i18next's
changelog</a>.</em></p>
<blockquote>
<h2>26.3.6</h2>
<ul>
<li>fix: allow TypeScript 7 in the optional <code>typescript</code> peer
dependency range (<code>^5 || ^6 || ^7</code>). With
<code>typescript@7.0.2</code> in a project, <code>npm install</code>
failed with an <code>ERESOLVE</code> peer conflict. The published types
are TS7-compatible as-is: every <code>test/typescript</code> suite
produces identical results under 6.0 and 7.0.2. Reported in <a
href="https://redirect.github.com/i18next/react-i18next/issues/1927">react-i18next#1927</a>,
thanks <a
href="https://github.com/andikapradanaarif"><code>@​andikapradanaarif</code></a>.</li>
</ul>
<h2>26.3.5</h2>
<ul>
<li>fix: <code>$t()</code> nesting options blocks that span multiple
lines are now parsed. <code>nest()</code> decided where the nested key
ends by testing <code>match[1]</code> with <code>/{.*}/</code>, whose
dot does not cross line breaks — so a <code>$t(key, { ... })</code>
options object containing a newline was treated as having no options,
mis-split as formatters, and the nested lookup ran without its options
(placeholders stayed unresolved). The nesting regexp itself already
matches newlines inside <code>$t(...)</code>; adding the <code>s</code>
(dotAll) flag makes multiline options behave like the single-line form.
Thanks <a href="https://github.com/spokodev"><code>@​spokodev</code></a>
(<a
href="https://redirect.github.com/i18next/i18next/pull/2440">#2440</a>).</li>
<li>fix: <code>getUsedParamsDetails</code> (the <code>returnDetails:
true</code> path) no longer mutates the passed <code>replace</code>
object. It wrote <code>count</code> straight onto
<code>options.replace</code> so the returned <code>usedParams</code>
would include it — a caller reusing one <code>replace</code> object
across <code>t()</code> calls then carried a stale <code>count</code>
into later interpolations (e.g. a previous call's <code>count: 5</code>
rendered instead of the current call's value). The details are now built
from a copy; <code>usedParams</code> still includes <code>count</code>.
Thanks <a href="https://github.com/spokodev"><code>@​spokodev</code></a>
(<a
href="https://redirect.github.com/i18next/i18next/pull/2441">#2441</a>).</li>
<li>fix: with the default <code>skipOnVariables: true</code> +
<code>escapeValue: true</code>, a <code>{{placeholder}}</code> carried
inside an interpolated value now stays literal even when the value
contains escapable characters. The skip logic advanced the regex
<code>lastIndex</code> by the raw value length, but the escaped text
written into the string is longer, so <code>lastIndex</code> landed
inside the inserted value and a trailing <code>{{placeholder}}</code> in
it got interpolated — leaking another in-scope variable that should have
stayed literal (values without escapable characters were already skipped
correctly). The advance now uses the escaped length that is actually
written, and the regex-safe <code>$</code>-doubling is applied only at
the <code>String.replace</code> call so it can't distort the length
arithmetic. Thanks <a
href="https://github.com/spokodev"><code>@​spokodev</code></a> (<a
href="https://redirect.github.com/i18next/i18next/pull/2442">#2442</a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/i18next/i18next/commit/e1c60d4dd28a16f91be7f55b3685ffcf9760619b"><code>e1c60d4</code></a>
26.3.6</li>
<li><a
href="https://github.com/i18next/i18next/commit/04da43e08cb799f2e6f9a5578f3674fe2ef49595"><code>04da43e</code></a>
fix: allow typescript 7 in optional peerDependencies range
(react-i18next#1927)</li>
<li><a
href="https://github.com/i18next/i18next/commit/8eed4accc6c69398e9e25f79b617249fd3f7b1ec"><code>8eed4ac</code></a>
build</li>
<li><a
href="https://github.com/i18next/i18next/commit/573ae735682a082ad5a581dea3f2d5f517c53772"><code>573ae73</code></a>
26.3.5</li>
<li><a
href="https://github.com/i18next/i18next/commit/cc54b05b5cab9bf76cf66557c222c978bbb3c082"><code>cc54b05</code></a>
docs(changelog): 26.3.5 — multiline $t() options, replace mutation,
escaped-l...</li>
<li><a
href="https://github.com/i18next/i18next/commit/3180d67291a8fff10950f79660420c106c047f94"><code>3180d67</code></a>
fix: skip interpolation of placeholders inside escaped values (<a
href="https://redirect.github.com/i18next/i18next/issues/2442">#2442</a>)</li>
<li><a
href="https://github.com/i18next/i18next/commit/d16f5a2da748082155007ba0677d32b877b1102e"><code>d16f5a2</code></a>
fix: stop mutating the passed replace object when returning details (<a
href="https://redirect.github.com/i18next/i18next/issues/2441">#2441</a>)</li>
<li><a
href="https://github.com/i18next/i18next/commit/bed56c115979de52bd85c46d9b7a7b13361bf736"><code>bed56c1</code></a>
fix: parse $t() nesting options block that spans multiple lines (<a
href="https://redirect.github.com/i18next/i18next/issues/2440">#2440</a>)</li>
<li><a
href="https://github.com/i18next/i18next/commit/c19e45864f3abbbe90e30be44cc131f2248394bf"><code>c19e458</code></a>
docs(changelog): link GHSA advisory for deepExtend fix</li>
<li><a
href="https://github.com/i18next/i18next/commit/7bb87d09f907b4395ed0825638d7d2c4356f7a89"><code>7bb87d0</code></a>
docs(changelog): reference security advisory for deepExtend fix</li>
<li>Additional commits viewable in <a
href="https://github.com/i18next/i18next/compare/v26.3.4...v26.3.6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=i18next&package-manager=npm_and_yarn&previous-version=26.3.4&new-version=26.3.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 13:52:30 +00:00
64 changed files with 5076 additions and 4752 deletions
+5 -5
View File
@@ -23,7 +23,7 @@ jobs:
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: { persist-credentials: false } with: { persist-credentials: false }
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
@@ -43,7 +43,7 @@ jobs:
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: { persist-credentials: false } with: { persist-credentials: false }
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
@@ -92,7 +92,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: { persist-credentials: false } with: { persist-credentials: false }
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
@@ -113,7 +113,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: { persist-credentials: false } with: { persist-credentials: false }
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
@@ -141,7 +141,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: { persist-credentials: false } with: { persist-credentials: false }
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
ref: main ref: main
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
with: { persist-credentials: false } with: { persist-credentials: false }
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
@@ -48,7 +48,7 @@ jobs:
ref: main ref: main
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
+2 -2
View File
@@ -32,10 +32,10 @@ export default defineConfig({
"@stylistic": stylisticTs "@stylistic": stylisticTs
}, },
rules: { rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react-refresh/only-export-components": [ "react-refresh/only-export-components": [
"warn", "warn",
// Many components are exported as withTranslation()(Component); without
// this the rule cannot tell the HOC's result is still a component.
{ allowConstantExport: true, extraHOCs: ["withTranslation"] } { allowConstantExport: true, extraHOCs: ["withTranslation"] }
], ],
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
+82 -82
View File
@@ -20,7 +20,7 @@
"@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-geocoder": "^1.9.4", "@maplibre/maplibre-gl-geocoder": "^1.9.4",
"@maplibre/maplibre-gl-inspect": "^1.8.2", "@maplibre/maplibre-gl-inspect": "^1.8.2",
"@maplibre/maplibre-gl-style-spec": "^25.0.2", "@maplibre/maplibre-gl-style-spec": "^26.1.0",
"array-move": "^4.0.0", "array-move": "^4.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -30,7 +30,7 @@
"downshift": "^9.4.0", "downshift": "^9.4.0",
"events": "^3.3.0", "events": "^3.3.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^26.3.4", "i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
@@ -92,7 +92,7 @@
"@vitejs/plugin-react": "5.2", "@vitejs/plugin-react": "5.2",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"cors": "^2.8.6", "cors": "^2.8.6",
"eslint": "^10.6.0", "eslint": "^10.7.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
@@ -100,14 +100,14 @@
"istanbul": "^0.4.5", "istanbul": "^0.4.5",
"istanbul-lib-coverage": "^3.2.2", "istanbul-lib-coverage": "^3.2.2",
"nyc": "^18.0.0", "nyc": "^18.0.0",
"postcss": "^8.5.16", "postcss": "^8.5.19",
"react-hot-loader": "^4.13.1", "react-hot-loader": "^4.13.1",
"sass": "^1.101.0", "sass": "^1.101.0",
"stylelint": "^17.14.0", "stylelint": "^17.14.0",
"stylelint-config-recommended-scss": "^17.0.1", "stylelint-config-recommended-scss": "^17.0.1",
"stylelint-scss": "^7.2.0", "stylelint-scss": "^7.2.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.62.1", "typescript-eslint": "^8.64.0",
"uuid": "^14.0.1", "uuid": "^14.0.1",
"vite": "^7.3.2", "vite": "^7.3.2",
"vite-plugin-istanbul": "^9.0.1", "vite-plugin-istanbul": "^9.0.1",
@@ -2179,9 +2179,9 @@
} }
}, },
"node_modules/@maplibre/maplibre-gl-style-spec": { "node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "25.0.2", "version": "26.1.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-25.0.2.tgz", "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.1.0.tgz",
"integrity": "sha512-hTNlIVG1gaLKz6UvOV23PYcT8wVS69LLMK9jbGT2hDWzs6hlTbMejeMyQsAF0rebj9czVp77Vhp4KRaUHHUp9g==", "integrity": "sha512-JaVj0XqDWFb2xfaRFCvF2Gi7qSpCYXXJlVghDDfRUV2dks61YsQKDjlZKT3H+oVwFBxX7CjErz9zjdGTEP/Lug==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3", "@mapbox/jsonlint-lines-primitives": "^2.0.3",
@@ -3741,17 +3741,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
"integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/type-utils": "8.64.0",
"@typescript-eslint/utils": "8.62.1", "@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/visitor-keys": "8.62.1", "@typescript-eslint/visitor-keys": "8.64.0",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
@@ -3764,15 +3764,15 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.62.1", "@typescript-eslint/parser": "^8.64.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3780,16 +3780,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz",
"integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.62.1", "@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/visitor-keys": "8.62.1", "@typescript-eslint/visitor-keys": "8.64.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -3805,14 +3805,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz",
"integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.62.1", "@typescript-eslint/tsconfig-utils": "^8.64.0",
"@typescript-eslint/types": "^8.62.1", "@typescript-eslint/types": "^8.64.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -3827,14 +3827,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz",
"integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.62.1", "@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.62.1" "@typescript-eslint/visitor-keys": "8.64.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3845,9 +3845,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz",
"integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3862,15 +3862,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz",
"integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.62.1", "@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.62.1", "@typescript-eslint/utils": "8.64.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
}, },
@@ -3887,9 +3887,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz",
"integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3901,16 +3901,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz",
"integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.62.1", "@typescript-eslint/project-service": "8.64.0",
"@typescript-eslint/tsconfig-utils": "8.62.1", "@typescript-eslint/tsconfig-utils": "8.64.0",
"@typescript-eslint/types": "8.62.1", "@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.62.1", "@typescript-eslint/visitor-keys": "8.64.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
@@ -3968,16 +3968,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz",
"integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.62.1", "@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.62.1" "@typescript-eslint/typescript-estree": "8.64.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3992,13 +3992,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz",
"integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.62.1", "@typescript-eslint/types": "8.64.0",
"eslint-visitor-keys": "^5.0.0" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
@@ -5857,9 +5857,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "10.6.0", "version": "10.7.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@@ -7230,9 +7230,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.4", "version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==", "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -7249,7 +7249,7 @@
], ],
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"typescript": "^5 || ^6" "typescript": "^5 || ^6 || ^7"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"typescript": { "typescript": {
@@ -10755,9 +10755,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -12985,16 +12985,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.62.1", "version": "8.64.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz",
"integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.62.1", "@typescript-eslint/eslint-plugin": "8.64.0",
"@typescript-eslint/parser": "8.62.1", "@typescript-eslint/parser": "8.64.0",
"@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.62.1" "@typescript-eslint/utils": "8.64.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+5 -5
View File
@@ -37,7 +37,7 @@
"@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-geocoder": "^1.9.4", "@maplibre/maplibre-gl-geocoder": "^1.9.4",
"@maplibre/maplibre-gl-inspect": "^1.8.2", "@maplibre/maplibre-gl-inspect": "^1.8.2",
"@maplibre/maplibre-gl-style-spec": "^25.0.2", "@maplibre/maplibre-gl-style-spec": "^26.1.0",
"array-move": "^4.0.0", "array-move": "^4.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -47,7 +47,7 @@
"downshift": "^9.4.0", "downshift": "^9.4.0",
"events": "^3.3.0", "events": "^3.3.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^26.3.4", "i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
@@ -126,7 +126,7 @@
"@vitejs/plugin-react": "5.2", "@vitejs/plugin-react": "5.2",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"cors": "^2.8.6", "cors": "^2.8.6",
"eslint": "^10.6.0", "eslint": "^10.7.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
@@ -134,14 +134,14 @@
"istanbul": "^0.4.5", "istanbul": "^0.4.5",
"istanbul-lib-coverage": "^3.2.2", "istanbul-lib-coverage": "^3.2.2",
"nyc": "^18.0.0", "nyc": "^18.0.0",
"postcss": "^8.5.16", "postcss": "^8.5.19",
"react-hot-loader": "^4.13.1", "react-hot-loader": "^4.13.1",
"sass": "^1.101.0", "sass": "^1.101.0",
"stylelint": "^17.14.0", "stylelint": "^17.14.0",
"stylelint-config-recommended-scss": "^17.0.1", "stylelint-config-recommended-scss": "^17.0.1",
"stylelint-scss": "^7.2.0", "stylelint-scss": "^7.2.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.62.1", "typescript-eslint": "^8.64.0",
"uuid": "^14.0.1", "uuid": "^14.0.1",
"vite": "^7.3.2", "vite": "^7.3.2",
"vite-plugin-istanbul": "^9.0.1", "vite-plugin-istanbul": "^9.0.1",
+577 -620
View File
File diff suppressed because it is too large Load Diff
+31 -28
View File
@@ -13,38 +13,41 @@ type AppLayoutInternalProps = {
modals?: React.ReactNode modals?: React.ReactNode
} & WithTranslation; } & WithTranslation;
const AppLayoutInternal: React.FC<AppLayoutInternalProps> = (props) => { class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
document.body.dir = props.i18n.dir();
return <IconContext.Provider value={{size: "14px"}}> render() {
<div className="maputnik-layout"> document.body.dir = this.props.i18n.dir();
{props.toolbar}
<div className="maputnik-layout-main"> return <IconContext.Provider value={{size: "14px"}}>
{props.codeEditor && <div className="maputnik-layout-code-editor"> <div className="maputnik-layout">
<ScrollContainer> {this.props.toolbar}
{props.codeEditor} <div className="maputnik-layout-main">
</ScrollContainer> {this.props.codeEditor && <div className="maputnik-layout-code-editor">
</div>
}
{!props.codeEditor && <>
<div className="maputnik-layout-list">
{props.layerList}
</div>
<div className="maputnik-layout-drawer">
<ScrollContainer> <ScrollContainer>
{props.layerEditor} {this.props.codeEditor}
</ScrollContainer> </ScrollContainer>
</div> </div>
</>} }
{props.map} {!this.props.codeEditor && <>
<div className="maputnik-layout-list">
{this.props.layerList}
</div>
<div className="maputnik-layout-drawer">
<ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div>
</>}
{this.props.map}
</div>
{this.props.bottom && <div className="maputnik-layout-bottom">
{this.props.bottom}
</div>
}
{this.props.modals}
</div> </div>
{props.bottom && <div className="maputnik-layout-bottom"> </IconContext.Provider>;
{props.bottom} }
</div> }
}
{props.modals}
</div>
</IconContext.Provider>;
};
export const AppLayout = withTranslation()(AppLayoutInternal); export const AppLayout = withTranslation()(AppLayoutInternal);
+45 -42
View File
@@ -13,49 +13,52 @@ type AppMessagePanelInternalProps = {
selectedLayerIndex?: number selectedLayerIndex?: number
} & WithTranslation; } & WithTranslation;
const AppMessagePanelInternal: React.FC<AppMessagePanelInternalProps> = ({ class AppMessagePanelInternal extends React.Component<AppMessagePanelInternalProps> {
onLayerSelect = () => { }, static defaultProps = {
...props onLayerSelect: () => { },
}) => { };
const { t, selectedLayerIndex } = props;
const errors = props.errors?.map((error, idx) => {
let content;
if (error.parsed && error.parsed.type === "layer") {
const { parsed } = error;
const layerId = props.mapStyle?.layers[parsed.data.index].id;
content = (
<>
{t("Layer")} <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
{selectedLayerIndex !== parsed.data.index &&
<>
&nbsp;&mdash;&nbsp;
<button
className="maputnik-message-panel__switch-button"
onClick={() => onLayerSelect!(parsed.data.index)}
>
{t("switch to layer")}
</button>
</>
}
</>
);
}
else {
content = error.message;
}
return <p key={"error-" + idx} className="maputnik-message-panel-error">
{content}
</p>;
});
const infos = props.infos?.map((m, i) => { render() {
return <p key={"info-" + i}>{m}</p>; const { t, selectedLayerIndex } = this.props;
}); const errors = this.props.errors?.map((error, idx) => {
let content;
if (error.parsed && error.parsed.type === "layer") {
const { parsed } = error;
const layerId = this.props.mapStyle?.layers[parsed.data.index].id;
content = (
<>
{t("Layer")} <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
{selectedLayerIndex !== parsed.data.index &&
<>
&nbsp;&mdash;&nbsp;
<button
className="maputnik-message-panel__switch-button"
onClick={() => this.props.onLayerSelect!(parsed.data.index)}
>
{t("switch to layer")}
</button>
</>
}
</>
);
}
else {
content = error.message;
}
return <p key={"error-" + idx} className="maputnik-message-panel-error">
{content}
</p>;
});
return <div className="maputnik-message-panel"> const infos = this.props.infos?.map((m, i) => {
{errors} return <p key={"info-" + i}>{m}</p>;
{infos} });
</div>;
}; return <div className="maputnik-message-panel">
{errors}
{infos}
</div>;
}
}
export const AppMessagePanel = withTranslation()(AppMessagePanelInternal); export const AppMessagePanel = withTranslation()(AppMessagePanelInternal);
+211 -191
View File
@@ -31,9 +31,11 @@ type IconTextProps = {
}; };
const IconText: React.FC<IconTextProps> = (props) => { class IconText extends React.Component<IconTextProps> {
return <span className="maputnik-icon-text">{props.children}</span>; render() {
}; return <span className="maputnik-icon-text">{this.props.children}</span>;
}
}
type ToolbarLinkProps = { type ToolbarLinkProps = {
className?: string className?: string
@@ -41,31 +43,35 @@ type ToolbarLinkProps = {
href?: string href?: string
}; };
const ToolbarLink: React.FC<ToolbarLinkProps> = (props) => { class ToolbarLink extends React.Component<ToolbarLinkProps> {
return <a render() {
className={classnames("maputnik-toolbar-link", props.className)} return <a
href={props.href} className={classnames("maputnik-toolbar-link", this.props.className)}
rel="noopener noreferrer" href={this.props.href}
target="_blank" rel="noopener noreferrer"
data-wd-key="toolbar:link" target="_blank"
> data-wd-key="toolbar:link"
{props.children} >
</a>; {this.props.children}
}; </a>;
}
}
type ToolbarSelectProps = { type ToolbarSelectProps = {
children?: React.ReactNode children?: React.ReactNode
wdKey?: string wdKey?: string
}; };
const ToolbarSelect: React.FC<ToolbarSelectProps> = (props) => { class ToolbarSelect extends React.Component<ToolbarSelectProps> {
return <div render() {
className='maputnik-toolbar-select' return <div
data-wd-key={props.wdKey} className='maputnik-toolbar-select'
> data-wd-key={this.props.wdKey}
{props.children} >
</div>; {this.props.children}
}; </div>;
}
}
type ToolbarActionProps = { type ToolbarActionProps = {
children?: React.ReactNode children?: React.ReactNode
@@ -73,15 +79,17 @@ type ToolbarActionProps = {
wdKey?: string wdKey?: string
}; };
const ToolbarAction: React.FC<ToolbarActionProps> = (props) => { class ToolbarAction extends React.Component<ToolbarActionProps> {
return <button render() {
className='maputnik-toolbar-action' return <button
data-wd-key={props.wdKey} className='maputnik-toolbar-action'
onClick={props.onClick} data-wd-key={this.props.wdKey}
> onClick={this.props.onClick}
{props.children} >
</button>; {this.props.children}
}; </button>;
}
}
export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deuteranopia" | "filter-protanopia" | "filter-tritanopia"; export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deuteranopia" | "filter-protanopia" | "filter-tritanopia";
@@ -100,16 +108,26 @@ type AppToolbarInternalProps = {
renderer?: string renderer?: string
} & WithTranslation; } & WithTranslation;
const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => { class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
function handleSelection(val: MapState) { state = {
props.onSetMapState(val); isOpen: {
settings: false,
sources: false,
open: false,
add: false,
export: false,
}
};
handleSelection(val: MapState) {
this.props.onSetMapState(val);
} }
function handleLanguageChange(val: string) { handleLanguageChange(val: string) {
props.i18n.changeLanguage(val); this.props.i18n.changeLanguage(val);
} }
const onSkip = (target: string) => { onSkip = (target: string) => {
if (target === "map") { if (target === "map") {
(document.querySelector(".maplibregl-canvas") as HTMLCanvasElement).focus(); (document.querySelector(".maplibregl-canvas") as HTMLCanvasElement).focus();
} }
@@ -119,171 +137,173 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
} }
}; };
const t = props.t; render() {
const views = [ const t = this.props.t;
{ const views = [
id: "map", {
group: "general", id: "map",
title: t("Map"), group: "general",
}, title: t("Map"),
{ },
id: "inspect", {
group: "general", id: "inspect",
title: t("Inspect"), group: "general",
disabled: props.renderer === "ol", title: t("Inspect"),
}, disabled: this.props.renderer === "ol",
{ },
id: "filter-deuteranopia", {
group: "color-accessibility", id: "filter-deuteranopia",
title: t("Deuteranopia filter"), group: "color-accessibility",
disabled: !colorAccessibilityFiltersEnabled, title: t("Deuteranopia filter"),
}, disabled: !colorAccessibilityFiltersEnabled,
{ },
id: "filter-protanopia", {
group: "color-accessibility", id: "filter-protanopia",
title: t("Protanopia filter"), group: "color-accessibility",
disabled: !colorAccessibilityFiltersEnabled, title: t("Protanopia filter"),
}, disabled: !colorAccessibilityFiltersEnabled,
{ },
id: "filter-tritanopia", {
group: "color-accessibility", id: "filter-tritanopia",
title: t("Tritanopia filter"), group: "color-accessibility",
disabled: !colorAccessibilityFiltersEnabled, title: t("Tritanopia filter"),
}, disabled: !colorAccessibilityFiltersEnabled,
{ },
id: "filter-achromatopsia", {
group: "color-accessibility", id: "filter-achromatopsia",
title: t("Achromatopsia filter"), group: "color-accessibility",
disabled: !colorAccessibilityFiltersEnabled, title: t("Achromatopsia filter"),
}, disabled: !colorAccessibilityFiltersEnabled,
]; },
];
const currentView = views.find((view) => { const currentView = views.find((view) => {
return view.id === props.mapState; return view.id === this.props.mapState;
}); });
return <nav className='maputnik-toolbar'> return <nav className='maputnik-toolbar'>
<div className="maputnik-toolbar__inner"> <div className="maputnik-toolbar__inner">
<div <div
className="maputnik-toolbar-logo-container" className="maputnik-toolbar-logo-container"
>
{/* Keyboard accessible quick links */}
<button
data-wd-key="root:skip:layer-list"
className="maputnik-toolbar-skip"
onClick={_e => onSkip("layer-list")}
> >
{t("Layers list")} {/* Keyboard accessible quick links */}
</button> <button
<button data-wd-key="root:skip:layer-list"
data-wd-key="root:skip:layer-editor" className="maputnik-toolbar-skip"
className="maputnik-toolbar-skip" onClick={_e => this.onSkip("layer-list")}
onClick={_e => onSkip("layer-editor")} >
> {t("Layers list")}
{t("Layer editor")} </button>
</button> <button
<button data-wd-key="root:skip:layer-editor"
data-wd-key="root:skip:map-view" className="maputnik-toolbar-skip"
className="maputnik-toolbar-skip" onClick={_e => this.onSkip("layer-editor")}
onClick={_e => onSkip("map")} >
> {t("Layer editor")}
{t("Map view")} </button>
</button> <button
<a data-wd-key="root:skip:map-view"
className="maputnik-toolbar-logo" className="maputnik-toolbar-skip"
target="blank" onClick={_e => this.onSkip("map")}
rel="noreferrer noopener" >
href="https://github.com/maplibre/maputnik" {t("Map view")}
> </button>
<img src={maputnikLogo} alt={t("Maputnik on GitHub")} /> <a
<h1> className="maputnik-toolbar-logo"
<span className="maputnik-toolbar-name">{pkgJson.name}</span> target="blank"
<span className="maputnik-toolbar-version">v{pkgJson.version}</span> rel="noreferrer noopener"
</h1> href="https://github.com/maplibre/maputnik"
</a> >
</div> <img src={maputnikLogo} alt={t("Maputnik on GitHub")} />
<div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar"> <h1>
<ToolbarAction wdKey="nav:open" onClick={() => props.onToggleModal("open")}> <span className="maputnik-toolbar-name">{pkgJson.name}</span>
<MdOpenInBrowser /> <span className="maputnik-toolbar-version">v{pkgJson.version}</span>
<IconText>{t("Open")}</IconText> </h1>
</ToolbarAction> </a>
<ToolbarAction wdKey="nav:export" onClick={() => props.onToggleModal("export")}> </div>
<MdSave /> <div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar">
<IconText>{t("Save")}</IconText> <ToolbarAction wdKey="nav:open" onClick={() => this.props.onToggleModal("open")}>
</ToolbarAction> <MdOpenInBrowser />
<ToolbarAction wdKey="nav:code-editor" onClick={() => props.onToggleModal("codeEditor")}> <IconText>{t("Open")}</IconText>
<MdCode /> </ToolbarAction>
<IconText>{t("Code Editor")}</IconText> <ToolbarAction wdKey="nav:export" onClick={() => this.props.onToggleModal("export")}>
</ToolbarAction> <MdSave />
<ToolbarAction wdKey="nav:sources" onClick={() => props.onToggleModal("sources")}> <IconText>{t("Save")}</IconText>
<MdLayers /> </ToolbarAction>
<IconText>{t("Data Sources")}</IconText> <ToolbarAction wdKey="nav:code-editor" onClick={() => this.props.onToggleModal("codeEditor")}>
</ToolbarAction> <MdCode />
<ToolbarAction wdKey="nav:settings" onClick={() => props.onToggleModal("settings")}> <IconText>{t("Code Editor")}</IconText>
<MdSettings /> </ToolbarAction>
<IconText>{t("Style Settings")}</IconText> <ToolbarAction wdKey="nav:sources" onClick={() => this.props.onToggleModal("sources")}>
</ToolbarAction> <MdLayers />
<ToolbarAction wdKey="nav:global-state" onClick={() => props.onToggleModal("globalState")}> <IconText>{t("Data Sources")}</IconText>
<MdPublic /> </ToolbarAction>
<IconText>{t("Global State")}</IconText> <ToolbarAction wdKey="nav:settings" onClick={() => this.props.onToggleModal("settings")}>
</ToolbarAction> <MdSettings />
<IconText>{t("Style Settings")}</IconText>
</ToolbarAction>
<ToolbarAction wdKey="nav:global-state" onClick={() => this.props.onToggleModal("globalState")}>
<MdPublic />
<IconText>{t("Global State")}</IconText>
</ToolbarAction>
<ToolbarSelect wdKey="nav:inspect"> <ToolbarSelect wdKey="nav:inspect">
<MdFindInPage /> <MdFindInPage />
<IconText>{t("View")} <IconText>{t("View")}
<select <select
className="maputnik-select" className="maputnik-select"
data-wd-key="maputnik-select" data-wd-key="maputnik-select"
onChange={(e) => handleSelection(e.target.value as MapState)} onChange={(e) => this.handleSelection(e.target.value as MapState)}
value={currentView?.id} value={currentView?.id}
> >
{views.filter(v => v.group === "general").map((item) => { {views.filter(v => v.group === "general").map((item) => {
return (
<option key={item.id} value={item.id} disabled={item.disabled} data-wd-key={item.id}>
{item.title}
</option>
);
})}
<optgroup label={t("Color accessibility")}>
{views.filter(v => v.group === "color-accessibility").map((item) => {
return ( return (
<option key={item.id} value={item.id} disabled={item.disabled}> <option key={item.id} value={item.id} disabled={item.disabled} data-wd-key={item.id}>
{item.title} {item.title}
</option> </option>
); );
})} })}
</optgroup> <optgroup label={t("Color accessibility")}>
</select> {views.filter(v => v.group === "color-accessibility").map((item) => {
</IconText> return (
</ToolbarSelect> <option key={item.id} value={item.id} disabled={item.disabled}>
{item.title}
</option>
);
})}
</optgroup>
</select>
</IconText>
</ToolbarSelect>
<ToolbarSelect wdKey="nav:language"> <ToolbarSelect wdKey="nav:language">
<MdLanguage /> <MdLanguage />
<IconText>Language <IconText>Language
<select <select
className="maputnik-select" className="maputnik-select"
data-wd-key="maputnik-lang-select" data-wd-key="maputnik-lang-select"
onChange={(e) => handleLanguageChange(e.target.value)} onChange={(e) => this.handleLanguageChange(e.target.value)}
value={props.i18n.language} value={this.props.i18n.language}
> >
{Object.entries(supportedLanguages).map(([code, name]) => { {Object.entries(supportedLanguages).map(([code, name]) => {
return ( return (
<option key={code} value={code}> <option key={code} value={code}>
{name} {name}
</option> </option>
); );
})} })}
</select> </select>
</IconText> </IconText>
</ToolbarSelect> </ToolbarSelect>
<ToolbarLink href={"https://github.com/maplibre/maputnik/wiki"}> <ToolbarLink href={"https://github.com/maplibre/maputnik/wiki"}>
<MdHelpOutline /> <MdHelpOutline />
<IconText>{t("Help")}</IconText> <IconText>{t("Help")}</IconText>
</ToolbarLink> </ToolbarLink>
</div>
</div> </div>
</div> </nav>;
</nav>; }
}; }
export const AppToolbar = withTranslation()(AppToolbarInternal); export const AppToolbar = withTranslation()(AppToolbarInternal);
+68 -47
View File
@@ -1,4 +1,4 @@
import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent, useRef, useState} from "react"; import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent} from "react";
import classnames from "classnames"; import classnames from "classnames";
import { FieldDocLabel } from "./FieldDocLabel"; import { FieldDocLabel } from "./FieldDocLabel";
import { Doc } from "./Doc"; import { Doc } from "./Doc";
@@ -14,13 +14,32 @@ export type BlockProps = PropsWithChildren & {
error?: {message: string} error?: {message: string}
}; };
/** Wrap a component with a label */ type BlockState = {
export const Block: React.FC<BlockProps> = (props) => { showDoc: boolean
const [showDoc, setShowDoc] = useState(false); };
const blockEl = useRef<HTMLDivElement | null>(null);
const onToggleDoc = (val: boolean) => { /** Wrap a component with a label */
setShowDoc(val); export class Block extends React.Component<BlockProps, BlockState> {
_blockEl: HTMLDivElement | null = null;
constructor (props: BlockProps) {
super(props);
this.state = {
showDoc: false,
};
}
onChange(e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) {
const value = e.target.value;
if (this.props.onChange) {
return this.props.onChange(value === "" ? undefined : value);
}
}
onToggleDoc = (val: boolean) => {
this.setState({
showDoc: val
});
}; };
/** /**
@@ -29,9 +48,9 @@ export const Block: React.FC<BlockProps> = (props) => {
* causing the picker to reopen. This causes a scenario where the picker can * causing the picker to reopen. This causes a scenario where the picker can
* never be closed once open. * never be closed once open.
*/ */
const onLabelClick = (event: SyntheticEvent<any, any>) => { onLabelClick = (event: SyntheticEvent<any, any>) => {
const el = event.nativeEvent.target; const el = event.nativeEvent.target;
const contains = blockEl.current?.contains(el); const contains = this._blockEl?.contains(el);
if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) { if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) {
event.stopPropagation(); event.stopPropagation();
@@ -41,43 +60,45 @@ export const Block: React.FC<BlockProps> = (props) => {
} }
}; };
return <label style={props.style} render() {
data-wd-key={props["data-wd-key"]} return <label style={this.props.style}
className={classnames({ data-wd-key={this.props["data-wd-key"]}
"maputnik-input-block": true, className={classnames({
"maputnik-input-block--wide": props.wideMode, "maputnik-input-block": true,
"maputnik-action-block": props.action, "maputnik-input-block--wide": this.props.wideMode,
"maputnik-input-block--error": props.error "maputnik-action-block": this.props.action,
})} "maputnik-input-block--error": this.props.error
onClick={onLabelClick} })}
> onClick={this.onLabelClick}
{props.fieldSpec && >
<div className="maputnik-input-block-label"> {this.props.fieldSpec &&
<FieldDocLabel <div className="maputnik-input-block-label">
label={props.label} <FieldDocLabel
onToggleDoc={onToggleDoc} label={this.props.label}
fieldSpec={props.fieldSpec} onToggleDoc={this.onToggleDoc}
/> fieldSpec={this.props.fieldSpec}
/>
</div>
}
{!this.props.fieldSpec &&
<div className="maputnik-input-block-label">
{this.props.label}
</div>
}
<div className="maputnik-input-block-action">
{this.props.action}
</div> </div>
} <div className="maputnik-input-block-content" ref={el => {this._blockEl = el;}}>
{!props.fieldSpec && {this.props.children}
<div className="maputnik-input-block-label">
{props.label}
</div> </div>
} {this.props.fieldSpec &&
<div className="maputnik-input-block-action"> <div
{props.action} className="maputnik-doc-inline"
</div> style={{display: this.state.showDoc ? "" : "none"}}
<div className="maputnik-input-block-content" ref={blockEl}> >
{props.children} <Doc fieldSpec={this.props.fieldSpec} />
</div> </div>
{props.fieldSpec && }
<div </label>;
className="maputnik-doc-inline" }
style={{display: showDoc ? "" : "none"}} }
>
<Doc fieldSpec={props.fieldSpec} />
</div>
}
</label>;
};
+21 -15
View File
@@ -9,19 +9,25 @@ type CollapseProps = {
}; };
export const Collapse: React.FC<CollapseProps> = ({isActive = true, children}) => { export class Collapse extends React.Component<CollapseProps> {
if (reducedMotionEnabled()) { static defaultProps = {
return ( isActive: true
<div style={{display: isActive ? "block" : "none"}}> };
{children}
</div> render() {
); if (reducedMotionEnabled()) {
return (
<div style={{display: this.props.isActive ? "block" : "none"}}>
{this.props.children}
</div>
);
}
else {
return (
<ReactCollapse isOpened={this.props.isActive}>
{this.props.children}
</ReactCollapse>
);
}
} }
else { }
return (
<ReactCollapse isOpened={isActive}>
{children}
</ReactCollapse>
);
}
};
+10 -8
View File
@@ -6,11 +6,13 @@ type CollapserProps = {
style?: object style?: object
}; };
export const Collapser: React.FC<CollapserProps> = (props) => { export class Collapser extends React.Component<CollapserProps> {
const iconStyle = { render() {
width: 20, const iconStyle = {
height: 20, width: 20,
...props.style, height: 20,
}; ...this.props.style,
return props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />; };
}; return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />;
}
}
+173 -156
View File
@@ -1,4 +1,4 @@
import React, { useRef } from "react"; import React from "react";
import {PiListPlusBold} from "react-icons/pi"; import {PiListPlusBold} from "react-icons/pi";
import {TbMathFunction} from "react-icons/tb"; import {TbMathFunction} from "react-icons/tb";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
@@ -68,19 +68,32 @@ export type Stop = [{
value: number value: number
}, number]; }, number];
const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => { class DataPropertyInternal extends React.Component<DataPropertyInternalProps, DataPropertyState> {
// Kept in a ref rather than state: the original recomputed these on every state = {
// render via getDerivedStateFromProps, which as state would mean setting refs: {} as {[key: number]: string}
// state during render on every pass. };
const refs = useRef<{[key: number]: string}>({});
// setStopRefs returns undefined when no new stop needs a ref; as in the componentDidMount() {
// original, only replace the map when it actually produced one. const newRefs = setStopRefs(this.props, this.state);
const newStopRefs = setStopRefs(props, { refs: refs.current });
if (newStopRefs) { if(newRefs) {
refs.current = newStopRefs; this.setState({
refs: newRefs
});
}
} }
function getFieldFunctionType(fieldSpec: any) { static getDerivedStateFromProps(props: Readonly<DataPropertyInternalProps>, state: DataPropertyState) {
const newRefs = setStopRefs(props, state);
if(newRefs) {
return {
refs: newRefs
};
}
return null;
}
getFieldFunctionType(fieldSpec: any) {
if (fieldSpec.expression.interpolated) { if (fieldSpec.expression.interpolated) {
return "exponential"; return "exponential";
} }
@@ -90,7 +103,7 @@ const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => {
return "categorical"; return "categorical";
} }
function getDataFunctionTypes(fieldSpec: any) { getDataFunctionTypes(fieldSpec: any) {
if (fieldSpec.expression.interpolated) { if (fieldSpec.expression.interpolated) {
return ["interpolate", "categorical", "interval", "exponential", "identity"]; return ["interpolate", "categorical", "interval", "exponential", "identity"];
} }
@@ -100,11 +113,11 @@ const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => {
} }
// Order the stops altering the refs to reflect their new position. // Order the stops altering the refs to reflect their new position.
function orderStopsByZoom(stops: Stop[]) { orderStopsByZoom(stops: Stop[]) {
const mappedWithRef = stops const mappedWithRef = stops
.map((stop, idx) => { .map((stop, idx) => {
return { return {
ref: refs.current[idx], ref: this.state.refs[idx],
data: stop data: stop
}; };
}) })
@@ -118,12 +131,14 @@ const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => {
newRefs[idx] = stop.ref; newRefs[idx] = stop.ref;
}); });
refs.current = newRefs; this.setState({
refs: newRefs
});
return mappedWithRef.map((item) => item.data); return mappedWithRef.map((item) => item.data);
} }
const onChange = (fieldName: string, value: any) => { onChange = (fieldName: string, value: any) => {
if (value.type === "identity") { if (value.type === "identity") {
value = { value = {
type: value.type, type: value.type,
@@ -137,17 +152,17 @@ const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => {
type: value.type, type: value.type,
// Default props if they don't already exist. // Default props if they don't already exist.
stops: [ stops: [
[{zoom: 6, value: stopValue}, findDefaultFromSpec(props.fieldSpec as any)], [{zoom: 6, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec as any)],
[{zoom: 10, value: stopValue}, findDefaultFromSpec(props.fieldSpec as any)] [{zoom: 10, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec as any)]
], ],
...value, ...value,
}; };
} }
props.onChange!(fieldName, value); this.props.onChange!(fieldName, value);
}; };
function changeStop(changeIdx: number, stopData: { zoom: number | undefined, value: number }, value: number) { changeStop(changeIdx: number, stopData: { zoom: number | undefined, value: number }, value: number) {
const stops = props.value?.stops.slice(0) || []; const stops = this.props.value?.stops.slice(0) || [];
// const changedStop = stopData.zoom === undefined ? stopData.value : stopData // const changedStop = stopData.zoom === undefined ? stopData.value : stopData
stops[changeIdx] = [ stops[changeIdx] = [
{ {
@@ -157,134 +172,135 @@ const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => {
value value
]; ];
const orderedStops = orderStopsByZoom(stops); const orderedStops = this.orderStopsByZoom(stops);
const changedValue = { const changedValue = {
...props.value, ...this.props.value,
stops: orderedStops, stops: orderedStops,
}; };
onChange(props.fieldName, changedValue); this.onChange(this.props.fieldName, changedValue);
} }
function changeBase(newValue: number | undefined) { changeBase(newValue: number | undefined) {
const changedValue = { const changedValue = {
...props.value, ...this.props.value,
base: newValue base: newValue
}; };
if (changedValue.base === undefined) { if (changedValue.base === undefined) {
delete changedValue["base"]; delete changedValue["base"];
} }
props.onChange!(props.fieldName, changedValue); this.props.onChange!(this.props.fieldName, changedValue);
} }
function changeDataType(propVal: string) { changeDataType(propVal: string) {
if (propVal === "interpolate" && props.onChangeToZoomFunction) { if (propVal === "interpolate" && this.props.onChangeToZoomFunction) {
props.onChangeToZoomFunction(); this.props.onChangeToZoomFunction();
} }
else { else {
onChange(props.fieldName, { this.onChange(this.props.fieldName, {
...props.value, ...this.props.value,
type: propVal, type: propVal,
}); });
} }
} }
function changeDataProperty(propName: "property" | "default", propVal: any) { changeDataProperty(propName: "property" | "default", propVal: any) {
if (propVal) { if (propVal) {
props.value![propName] = propVal; this.props.value![propName] = propVal;
} }
else { else {
delete props.value![propName]; delete this.props.value![propName];
} }
onChange(props.fieldName, props.value); this.onChange(this.props.fieldName, this.props.value);
} }
const t = props.t; render() {
const t = this.props.t;
if (typeof props.value?.type === "undefined") { if (typeof this.props.value?.type === "undefined") {
props.value!.type = getFieldFunctionType(props.fieldSpec); this.props.value!.type = this.getFieldFunctionType(this.props.fieldSpec);
} }
let dataFields; let dataFields;
if (props.value?.stops) { if (this.props.value?.stops) {
dataFields = props.value.stops.map((stop, idx) => { dataFields = this.props.value.stops.map((stop, idx) => {
const zoomLevel = typeof stop[0] === "object" ? stop[0].zoom : undefined; const zoomLevel = typeof stop[0] === "object" ? stop[0].zoom : undefined;
const key = refs.current[idx]; const key = this.state.refs[idx];
const dataLevel = typeof stop[0] === "object" ? stop[0].value : stop[0]; const dataLevel = typeof stop[0] === "object" ? stop[0].value : stop[0];
const value = stop[1]; const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={props.onDeleteStop?.bind(null, idx)} />; const deleteStopBtn = <DeleteStopButton onClick={this.props.onDeleteStop?.bind(this, idx)} />;
const dataProps = { const dataProps = {
"aria-label": t("Input value"), "aria-label": t("Input value"),
label: t("Data value"), label: t("Data value"),
value: dataLevel as any, value: dataLevel as any,
onChange: (newData: string | number | undefined) => changeStop(idx, { zoom: zoomLevel, value: newData as number }, value) onChange: (newData: string | number | undefined) => this.changeStop(idx, { zoom: zoomLevel, value: newData as number }, value)
}; };
let dataInput; let dataInput;
if(props.value?.type === "categorical") { if(this.props.value?.type === "categorical") {
dataInput = <InputString {...dataProps} />; dataInput = <InputString {...dataProps} />;
} }
else { else {
dataInput = <InputNumber {...dataProps} />; dataInput = <InputNumber {...dataProps} />;
} }
let zoomInput = null; let zoomInput = null;
if(zoomLevel !== undefined) { if(zoomLevel !== undefined) {
zoomInput = <div> zoomInput = <div>
<InputNumber <InputNumber
aria-label="Zoom" aria-label="Zoom"
value={zoomLevel} value={zoomLevel}
onChange={newZoom => changeStop(idx, {zoom: newZoom, value: dataLevel}, value)} onChange={newZoom => this.changeStop(idx, {zoom: newZoom, value: dataLevel}, value)}
min={0} min={0}
max={22} max={22}
/>
</div>;
}
return <tr key={key}>
<td>
{zoomInput}
</td>
<td>
{dataInput}
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={value}
onChange={(_, newValue) => changeStop(idx, {zoom: zoomLevel, value: dataLevel}, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
}
return <div className="maputnik-data-spec-block">
<fieldset className="maputnik-data-spec-property">
<legend>{labelFromFieldName(props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
key="function"
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={props.value!.type}
onChange={(propVal: string) => changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={getDataFunctionTypes(props.fieldSpec)}
/> />
</div> </div>;
</Block> }
{props.value?.type !== "identity" &&
return <tr key={key}>
<td>
{zoomInput}
</td>
<td>
{dataInput}
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={value}
onChange={(_, newValue) => this.changeStop(idx, {zoom: zoomLevel, value: dataLevel}, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
}
return <div className="maputnik-data-spec-block">
<fieldset className="maputnik-data-spec-property">
<legend>{labelFromFieldName(this.props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
key="function"
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={this.props.value!.type}
onChange={(propVal: string) => this.changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={this.getDataFunctionTypes(this.props.fieldSpec)}
/>
</div>
</Block>
{this.props.value?.type !== "identity" &&
<Block <Block
label={t("Base")} label={t("Base")}
key="base" key="base"
@@ -294,40 +310,40 @@ const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => {
<InputSpec <InputSpec
fieldName={"base"} fieldName={"base"}
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }} fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
value={props.value?.base} value={this.props.value?.base}
onChange={(_, newValue) => changeBase(newValue as number)} onChange={(_, newValue) => this.changeBase(newValue as number)}
/> />
</div> </div>
</Block> </Block>
} }
<Block <Block
label={"Property"} label={"Property"}
key="property" key="property"
data-wd-key="function-property" data-wd-key="function-property"
> >
<div className="maputnik-data-spec-property-input"> <div className="maputnik-data-spec-property-input">
<InputString <InputString
value={props.value?.property} value={this.props.value?.property}
title={t("Input a data property to base styles off of.")} title={t("Input a data property to base styles off of.")}
onChange={propVal => changeDataProperty("property", propVal)} onChange={propVal => this.changeDataProperty("property", propVal)}
/> />
</div> </div>
</Block> </Block>
{dataFields && {dataFields &&
<Block <Block
label={t("Default")} label={t("Default")}
key="default" key="default"
data-wd-key="function-default" data-wd-key="function-default"
> >
<InputSpec <InputSpec
fieldName={props.fieldName} fieldName={this.props.fieldName}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
value={props.value?.default} value={this.props.value?.default}
onChange={(_, propVal) => changeDataProperty("default", propVal)} onChange={(_, propVal) => this.changeDataProperty("default", propVal)}
/> />
</Block> </Block>
} }
{dataFields && {dataFields &&
<div className="maputnik-function-stop"> <div className="maputnik-function-stop">
<table className="maputnik-function-stop-table"> <table className="maputnik-function-stop-table">
<caption>{t("Stops")}</caption> <caption>{t("Stops")}</caption>
@@ -343,29 +359,30 @@ const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => {
</tbody> </tbody>
</table> </table>
</div> </div>
} }
<div className="maputnik-toolbox"> <div className="maputnik-toolbox">
{dataFields && {dataFields &&
<InputButton <InputButton
className="maputnik-add-stop" className="maputnik-add-stop"
onClick={props.onAddStop?.bind(null)} onClick={this.props.onAddStop?.bind(this)}
> >
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} /> <PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add stop")} {t("Add stop")}
</InputButton> </InputButton>
} }
<InputButton <InputButton
className="maputnik-add-stop" className="maputnik-add-stop"
data-wd-key="convert-to-expression" data-wd-key="convert-to-expression"
onClick={props.onExpressionClick?.bind(null)} onClick={this.props.onExpressionClick?.bind(this)}
> >
<TbMathFunction style={{ verticalAlign: "text-bottom" }} /> <TbMathFunction style={{ verticalAlign: "text-bottom" }} />
{t("Convert to expression")} {t("Convert to expression")}
</InputButton> </InputButton>
</div>
</div> </div>
</div> </fieldset>
</fieldset> </div>;
</div>; }
}; }
export const DataProperty = withTranslation()(DataPropertyInternal); export const DataProperty = withTranslation()(DataPropertyInternal);
+12 -10
View File
@@ -10,15 +10,17 @@ type DeleteStopButtonInternalProps = {
} & WithTranslation; } & WithTranslation;
const DeleteStopButtonInternal: React.FC<DeleteStopButtonInternalProps> = (props) => { class DeleteStopButtonInternal extends React.Component<DeleteStopButtonInternalProps> {
const t = props.t; render() {
return <InputButton const t = this.props.t;
className="maputnik-delete-stop" return <InputButton
onClick={props.onClick} className="maputnik-delete-stop"
title={t("Remove zoom level from stop")} onClick={this.props.onClick}
> title={t("Remove zoom level from stop")}
<MdDelete /> >
</InputButton>; <MdDelete />
}; </InputButton>;
}
}
export const DeleteStopButton = withTranslation()(DeleteStopButtonInternal); export const DeleteStopButton = withTranslation()(DeleteStopButtonInternal);
+80 -76
View File
@@ -23,84 +23,88 @@ type DocProps = {
} }
}; };
export const Doc: React.FC<DocProps> = ({fieldSpec}) => { export class Doc extends React.Component<DocProps> {
const {doc, values, docUrl, docUrlLinkText} = fieldSpec; render () {
const sdkSupport = fieldSpec["sdk-support"]; const {fieldSpec} = this.props;
const renderValues = ( const {doc, values, docUrl, docUrlLinkText} = fieldSpec;
!!values && const sdkSupport = fieldSpec["sdk-support"];
// HACK: Currently we merge additional values into the style spec, so this is required
// See <https://github.com/maplibre/maputnik/blob/main/src/components/PropertyGroup.jsx#L16>
!Array.isArray(values)
);
const sdkSupportToJsx = (value: string) => { const renderValues = (
const supportValue = value.toLowerCase(); !!values &&
if (supportValue.startsWith("https://")) { // HACK: Currently we merge additional values into the style spec, so this is required
return <a href={supportValue} target="_blank" rel="noreferrer">{"#" + supportValue.split("/").pop()}</a>; // See <https://github.com/maplibre/maputnik/blob/main/src/components/PropertyGroup.jsx#L16>
} !Array.isArray(values)
return value; );
};
return ( const sdkSupportToJsx = (value: string) => {
<> const supportValue = value.toLowerCase();
{doc && if (supportValue.startsWith("https://")) {
<div className="SpecDoc"> return <a href={supportValue} target="_blank" rel="noreferrer">{"#" + supportValue.split("/").pop()}</a>;
<div className="SpecDoc__doc" data-wd-key='spec-field-doc'>
<Markdown components={{
a: ({node: _node, href, children, ...props}) => <a href={href} target="_blank" {...props}>{children}</a>,
}}>{doc}</Markdown>
</div>
{renderValues &&
<ul className="SpecDoc__values">
{Object.entries(values).map(([key, value]) => {
return (
<li key={key}>
<code>{JSON.stringify(key)}</code>
<div>{value.doc}</div>
</li>
);
})}
</ul>
}
</div>
} }
{sdkSupport && return value;
<div className="SpecDoc__sdk-support"> };
<table className="SpecDoc__sdk-support__table">
<thead> return (
<tr> <>
<th></th> {doc &&
{Object.values(headers).map(header => { <div className="SpecDoc">
return <th key={header}>{header}</th>; <div className="SpecDoc__doc" data-wd-key='spec-field-doc'>
<Markdown components={{
a: ({node: _node, href, children, ...props}) => <a href={href} target="_blank" {...props}>{children}</a>,
}}>{doc}</Markdown>
</div>
{renderValues &&
<ul className="SpecDoc__values">
{Object.entries(values).map(([key, value]) => {
return (
<li key={key}>
<code>{JSON.stringify(key)}</code>
<div>{value.doc}</div>
</li>
);
})} })}
</tr> </ul>
</thead> }
<tbody> </div>
{Object.entries(sdkSupport).map(([key, supportObj]) => { }
return ( {sdkSupport &&
<tr key={key}> <div className="SpecDoc__sdk-support">
<td>{key}</td> <table className="SpecDoc__sdk-support__table">
{Object.keys(headers).map((k) => { <thead>
if (Object.prototype.hasOwnProperty.call(supportObj, k)) { <tr>
return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>; <th></th>
} {Object.values(headers).map(header => {
else { return <th key={header}>{header}</th>;
return <td key={k}>no</td>; })}
} </tr>
})} </thead>
</tr> <tbody>
); {Object.entries(sdkSupport).map(([key, supportObj]) => {
})} return (
</tbody> <tr key={key}>
</table> <td>{key}</td>
</div> {Object.keys(headers).map((k) => {
} if (Object.prototype.hasOwnProperty.call(supportObj, k)) {
{docUrl && docUrlLinkText && return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>;
<div className="SpecDoc__learn-more"> }
<a href={docUrl} target="_blank" rel="noreferrer">{docUrlLinkText}</a> else {
</div> return <td key={k}>no</td>;
} }
</> })}
); </tr>
}; );
})}
</tbody>
</table>
</div>
}
{docUrl && docUrlLinkText &&
<div className="SpecDoc__learn-more">
<a href={docUrl} target="_blank" rel="noreferrer">{docUrlLinkText}</a>
</div>
}
</>
);
}
}
+65 -55
View File
@@ -24,62 +24,72 @@ type ExpressionPropertyInternalProps = {
onBlur?(...args: unknown[]): unknown onBlur?(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const ExpressionPropertyInternal: React.FC<ExpressionPropertyInternalProps> = ({ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInternalProps> {
errors = {}, static defaultProps = {
onFocus = () => {}, errors: {},
onBlur = () => {}, onFocus: () => {},
...props onBlur: () => {},
}) => { };
const {t, value, canUndo} = props;
const undoDisabled = canUndo ? !canUndo() : true;
const deleteStopBtn = ( constructor(props: ExpressionPropertyInternalProps) {
<> super(props);
{props.onUndo && this.state = {
<InputButton jsonError: false,
key="undo_action" };
onClick={props.onUndo}
disabled={undoDisabled}
className="maputnik-delete-stop"
data-wd-key="undo-expression"
title={t("Revert from expression")}
>
<MdUndo />
</InputButton>
}
<InputButton
key="delete_action"
onClick={props.onDelete}
className="maputnik-delete-stop"
data-wd-key="delete-expression"
title={t("Delete expression")}
>
<MdDelete />
</InputButton>
</>
);
let error = undefined;
if (errors) {
const fieldKey = props.fieldType ? props.fieldType + "." + props.fieldName : props.fieldName;
error = errors[fieldKey];
} }
return <Block
fieldSpec={props.fieldSpec} render() {
label={t(labelFromFieldName(props.fieldName))} const {t, value, canUndo} = this.props;
action={deleteStopBtn} const undoDisabled = canUndo ? !canUndo() : true;
wideMode={true}
error={error} const deleteStopBtn = (
> <>
<FieldJson {this.props.onUndo &&
lintType="expression" <InputButton
spec={props.fieldSpec} key="undo_action"
className="maputnik-expression-editor" onClick={this.props.onUndo}
onFocus={onFocus} disabled={undoDisabled}
onBlur={onBlur} className="maputnik-delete-stop"
value={value} data-wd-key="undo-expression"
onChange={props.onChange} title={t("Revert from expression")}
/> >
</Block>; <MdUndo />
}; </InputButton>
}
<InputButton
key="delete_action"
onClick={this.props.onDelete}
className="maputnik-delete-stop"
data-wd-key="delete-expression"
title={t("Delete expression")}
>
<MdDelete />
</InputButton>
</>
);
let error = undefined;
if (this.props.errors) {
const fieldKey = this.props.fieldType ? this.props.fieldType + "." + this.props.fieldName : this.props.fieldName;
error = this.props.errors[fieldKey];
}
return <Block
fieldSpec={this.props.fieldSpec}
label={t(labelFromFieldName(this.props.fieldName))}
action={deleteStopBtn}
wideMode={true}
error={error}
>
<FieldJson
lintType="expression"
spec={this.props.fieldSpec}
className="maputnik-expression-editor"
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
value={value}
onChange={this.props.onChange}
/>
</Block>;
}
}
export const ExpressionProperty = withTranslation()(ExpressionPropertyInternal); export const ExpressionProperty = withTranslation()(ExpressionPropertyInternal);
+4 -1
View File
@@ -2,7 +2,10 @@ import { InputDynamicArray, type InputDynamicArrayProps } from "./InputDynamicAr
import { Fieldset } from "./Fieldset"; import { Fieldset } from "./Fieldset";
type FieldDynamicArrayProps = InputDynamicArrayProps & { type FieldDynamicArrayProps = InputDynamicArrayProps & {
name?: string name?: string;
fieldSpec?: {
value?: string;
}
}; };
export const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => { export const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => {
+174 -142
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
import { TbMathFunction } from "react-icons/tb"; import { TbMathFunction } from "react-icons/tb";
import { PiListPlusBold } from "react-icons/pi"; import { PiListPlusBold } from "react-icons/pi";
import {isEqual} from "lodash"; import {isEqual} from "lodash";
@@ -100,190 +100,222 @@ type FilterEditorInternalProps = {
onChange(value: LegacyFilterSpecification | ExpressionSpecification): void onChange(value: LegacyFilterSpecification | ExpressionSpecification): void
} & WithTranslation; } & WithTranslation;
const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["all"], ...rest }) => { type FilterEditorState = {
const props = { filter, ...rest } as FilterEditorInternalProps; showDoc: boolean
displaySimpleFilter: boolean
valueIsSimpleFilter?: boolean
};
// Nothing ever toggles this: the Block below renders its own documentation class FilterEditorInternal extends React.Component<FilterEditorInternalProps, FilterEditorState> {
// toggle, so this component's inline doc panel stays hidden (as it did before). static defaultProps = {
const [showDoc] = useState(false); filter: ["all"],
const [displaySimpleFilter, setDisplaySimpleFilter] = useState(() => };
checkIfSimpleFilter(combiningFilter(props))
);
// Replaces getDerivedStateFromProps. "Upgrade but never downgrade": once the constructor (props: FilterEditorInternalProps) {
// filter stops being expressible in the simple editor, switch to the super(props);
// expression editor and stay there. this.state = {
const isSimpleFilter = checkIfSimpleFilter(combiningFilter(props)); showDoc: false,
if (!isSimpleFilter && displaySimpleFilter) { displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)),
setDisplaySimpleFilter(false); };
} }
// In the original this was state, but every branch derived it from these two
// values alone.
const valueIsSimpleFilter = isSimpleFilter && !displaySimpleFilter;
// Convert filter to combining filter // Convert filter to combining filter
function onFilterPartChanged(filterIdx: number, newPart: any[]) { onFilterPartChanged(filterIdx: number, newPart: any[]) {
const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
newFilter[filterIdx] = newPart; newFilter[filterIdx] = newPart;
props.onChange(newFilter); this.props.onChange(newFilter);
} }
function deleteFilterItem(filterIdx: number) { deleteFilterItem(filterIdx: number) {
const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
newFilter.splice(filterIdx + 1, 1); newFilter.splice(filterIdx + 1, 1);
props.onChange(newFilter); this.props.onChange(newFilter);
} }
const addFilterItem = () => { addFilterItem = () => {
const newFilterItem = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilterItem = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
(newFilterItem as any[]).push(["==", "name", ""]); (newFilterItem as any[]).push(["==", "name", ""]);
props.onChange(newFilterItem); this.props.onChange(newFilterItem);
}; };
const makeFilter = () => { onToggleDoc = (val: boolean) => {
setDisplaySimpleFilter(true); this.setState({
showDoc: val
});
}; };
const makeExpression = () => { makeFilter = () => {
const currentFilter = combiningFilter(props); this.setState({
props.onChange(migrateFilter(currentFilter)); displaySimpleFilter: true,
setDisplaySimpleFilter(false); });
}; };
makeExpression = () => {
const {errors, t} = props; const filter = combiningFilter(this.props);
const fieldSpec={ this.props.onChange(migrateFilter(filter));
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter." this.setState({
displaySimpleFilter: false,
});
}; };
const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification;
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(props)); static getDerivedStateFromProps(props: Readonly<FilterEditorInternalProps>, state: FilterEditorState) {
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
if (isNestedCombiningFilter) { // Upgrade but never downgrade
return <div className="maputnik-filter-editor-unsupported"> if (!displaySimpleFilter && state.displaySimpleFilter === true) {
<p> return {
{t("Nested filters are not supported.")} displaySimpleFilter: false,
</p> valueIsSimpleFilter: false,
<InputButton };
onClick={makeExpression} }
title={t("Convert to expression")} else if (displaySimpleFilter && state.displaySimpleFilter === false) {
> return {
<TbMathFunction /> valueIsSimpleFilter: true,
{t("Upgrade to expression")} };
</InputButton> }
</div>; else {
return {
valueIsSimpleFilter: false,
};
}
} }
else if (displaySimpleFilter) {
const filter = combiningFilter(props);
const combiningOp = filter[0];
const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
const actions = ( render() {
<div> const {errors, t} = this.props;
const {displaySimpleFilter} = this.state;
const fieldSpec={
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
};
const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification;
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props));
if (isNestedCombiningFilter) {
return <div className="maputnik-filter-editor-unsupported">
<p>
{t("Nested filters are not supported.")}
</p>
<InputButton <InputButton
onClick={makeExpression} onClick={this.makeExpression}
title={t("Convert to expression")} title={t("Convert to expression")}
className="maputnik-make-zoom-function"
data-wd-key="filter-convert-to-expression"
> >
<TbMathFunction /> <TbMathFunction />
{t("Upgrade to expression")}
</InputButton> </InputButton>
</div> </div>;
); }
else if (displaySimpleFilter) {
const filter = combiningFilter(this.props);
const combiningOp = filter[0];
const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
const editorBlocks = filters.map((f, idx) => { const actions = (
const error = errors![`filter[${idx+1}]`]; <div>
return (
<div key={`block-${idx}`}>
<FilterEditorBlock key={idx} onDelete={deleteFilterItem.bind(null, idx)}>
<SingleFilterEditor
properties={props.properties}
filter={f}
onChange={onFilterPartChanged.bind(null, idx + 1)}
/>
</FilterEditorBlock>
{error &&
<div key="error" className="maputnik-inline-error">{error.message}</div>
}
</div>
);
});
return (
<>
<Block
key="top"
fieldSpec={fieldSpec}
label={t("Filter")}
action={actions}
data-wd-key="filter-combining-operator"
>
<InputSelect
value={combiningOp}
onChange={(v: [string, any]) => onFilterPartChanged(0, v)}
options={[
["all", t("every filter matches")],
["none", t("no filter matches")],
["any", t("any filter matches")]
]}
/>
</Block>
{editorBlocks}
<div
key="buttons"
className="maputnik-filter-editor-add-wrapper"
>
<InputButton <InputButton
data-wd-key="layer-filter-button" onClick={this.makeExpression}
className="maputnik-add-filter" title={t("Convert to expression")}
onClick={addFilterItem} className="maputnik-make-zoom-function"
data-wd-key="filter-convert-to-expression"
> >
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} /> <TbMathFunction />
{t("Add filter")}
</InputButton> </InputButton>
</div> </div>
<div );
key="doc"
className="maputnik-doc-inline"
style={{display: showDoc ? "" : "none"}}
>
<Doc fieldSpec={fieldSpec} />
</div>
</>
);
}
else {
const {filter} = props;
return ( const editorBlocks = filters.map((f, idx) => {
<> const error = errors![`filter[${idx+1}]`];
<ExpressionProperty
onDelete={() => { return (
setDisplaySimpleFilter(true); <div key={`block-${idx}`}>
props.onChange(defaultFilter); <FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
}} <SingleFilterEditor
fieldName="filter" properties={this.props.properties}
value={filter} filter={f}
errors={errors} onChange={this.onFilterPartChanged.bind(this, idx + 1)}
onChange={props.onChange} />
/> </FilterEditorBlock>
{valueIsSimpleFilter && {error &&
<div key="error" className="maputnik-inline-error">{error.message}</div>
}
</div>
);
});
return (
<>
<Block
key="top"
fieldSpec={fieldSpec}
label={t("Filter")}
action={actions}
data-wd-key="filter-combining-operator"
>
<InputSelect
value={combiningOp}
onChange={(v: [string, any]) => this.onFilterPartChanged(0, v)}
options={[
["all", t("every filter matches")],
["none", t("no filter matches")],
["any", t("any filter matches")]
]}
/>
</Block>
{editorBlocks}
<div
key="buttons"
className="maputnik-filter-editor-add-wrapper"
>
<InputButton
data-wd-key="layer-filter-button"
className="maputnik-add-filter"
onClick={this.addFilterItem}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add filter")}
</InputButton>
</div>
<div
key="doc"
className="maputnik-doc-inline"
style={{display: this.state.showDoc ? "" : "none"}}
>
<Doc fieldSpec={fieldSpec} />
</div>
</>
);
}
else {
const {filter} = this.props;
return (
<>
<ExpressionProperty
onDelete={() => {
this.setState({displaySimpleFilter: true});
this.props.onChange(defaultFilter);
}}
fieldName="filter"
value={filter}
errors={errors}
onChange={this.props.onChange}
/>
{this.state.valueIsSimpleFilter &&
<div className="maputnik-expr-infobox"> <div className="maputnik-expr-infobox">
{t("You've entered an old style filter.")} {t("You've entered an old style filter.")}
{" "} {" "}
<button <button
onClick={makeFilter} onClick={this.makeFilter}
className="maputnik-expr-infobox__button" className="maputnik-expr-infobox__button"
> >
{t("Switch to filter editor.")} {t("Switch to filter editor.")}
</button> </button>
</div> </div>
} }
</> </>
); );
}
} }
}; }
export const FilterEditor = withTranslation()(FilterEditorInternal); export const FilterEditor = withTranslation()(FilterEditorInternal);
+19 -17
View File
@@ -7,22 +7,24 @@ type FilterEditorBlockInternalProps = PropsWithChildren & {
onDelete(...args: unknown[]): unknown onDelete(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const FilterEditorBlockInternal: React.FC<FilterEditorBlockInternalProps> = (props) => { class FilterEditorBlockInternal extends React.Component<FilterEditorBlockInternalProps> {
const t = props.t; render() {
return <div className="maputnik-filter-editor-block"> const t = this.props.t;
<div className="maputnik-filter-editor-block-content"> return <div className="maputnik-filter-editor-block">
{props.children} <div className="maputnik-filter-editor-block-content">
</div> {this.props.children}
<div className="maputnik-filter-editor-block-action"> </div>
<InputButton <div className="maputnik-filter-editor-block-action">
className="maputnik-icon-button" <InputButton
onClick={props.onDelete} className="maputnik-icon-button"
title={t("Delete filter block")} onClick={this.props.onDelete}
> title={t("Delete filter block")}
<MdDelete /> >
</InputButton> <MdDelete />
</div> </InputButton>
</div>; </div>
}; </div>;
}
}
export const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal); export const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal);
+47 -45
View File
@@ -13,56 +13,58 @@ type FunctionInputButtonsInternalProps = {
onElevationClick?(): void onElevationClick?(): void
} & WithTranslation; } & WithTranslation;
const FunctionInputButtonsInternal: React.FC<FunctionInputButtonsInternalProps> = (props) => { class FunctionInputButtonsInternal extends React.Component<FunctionInputButtonsInternalProps> {
const t = props.t; render() {
const t = this.props.t;
if (props.fieldSpec.expression?.parameters.includes("zoom")) { if (this.props.fieldSpec.expression?.parameters.includes("zoom")) {
const expressionInputButton = ( const expressionInputButton = (
<InputButton <InputButton
className="maputnik-make-zoom-function"
onClick={this.props.onExpressionClick}
title={t("Convert to expression")}
>
<TbMathFunction />
</InputButton>
);
const makeZoomInputButton = <InputButton
className="maputnik-make-zoom-function" className="maputnik-make-zoom-function"
onClick={props.onExpressionClick} onClick={this.props.onZoomClick}
title={t("Convert to expression")} title={t("Convert property into a zoom function")}
> >
<TbMathFunction /> <MdFunctions />
</InputButton>
);
const makeZoomInputButton = <InputButton
className="maputnik-make-zoom-function"
onClick={props.onZoomClick}
title={t("Convert property into a zoom function")}
>
<MdFunctions />
</InputButton>;
let makeDataInputButton;
if (props.fieldSpec["property-type"] === "data-driven") {
makeDataInputButton = <InputButton
className="maputnik-make-data-function"
onClick={props.onDataClick}
title={t("Convert property to data function")}
>
<MdInsertChart />
</InputButton>; </InputButton>;
let makeDataInputButton;
if (this.props.fieldSpec["property-type"] === "data-driven") {
makeDataInputButton = <InputButton
className="maputnik-make-data-function"
onClick={this.props.onDataClick}
title={t("Convert property to data function")}
>
<MdInsertChart />
</InputButton>;
}
return <div>
{expressionInputButton}
{makeDataInputButton}
{makeZoomInputButton}
</div>;
} else if (this.props.fieldSpec.expression?.parameters.includes("elevation")) {
const inputElevationButton = <InputButton
className="maputnik-make-elevation-function"
onClick={this.props.onElevationClick}
title={t("Convert property into a elevation function")}
data-wd-key='make-elevation-function'
>
<MdFunctions />
</InputButton>;
return <div>{inputElevationButton}</div>;
} else {
return <div></div>;
} }
return <div>
{expressionInputButton}
{makeDataInputButton}
{makeZoomInputButton}
</div>;
} else if (props.fieldSpec.expression?.parameters.includes("elevation")) {
const inputElevationButton = <InputButton
className="maputnik-make-elevation-function"
onClick={props.onElevationClick}
title={t("Convert property into a elevation function")}
data-wd-key='make-elevation-function'
>
<MdFunctions />
</InputButton>;
return <div>{inputElevationButton}</div>;
} else {
return <div></div>;
} }
}; }
export const FunctionInputButtons = withTranslation()(FunctionInputButtonsInternal); export const FunctionInputButtons = withTranslation()(FunctionInputButtonsInternal);
+94 -60
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
import { InputString } from "./InputString"; import { InputString } from "./InputString";
import { InputNumber } from "./InputNumber"; import { InputNumber } from "./InputNumber";
@@ -12,72 +12,106 @@ export type InputArrayProps = {
label?: string label?: string
}; };
export const InputArray: React.FC<InputArrayProps> = ({ type InputArrayState = {
value: propsValue = [], value: (string | number | undefined)[]
default: propsDefault = [], initialPropsValue: unknown[]
...rest };
}) => {
const props = { value: propsValue, default: propsDefault, ...rest };
// The original seeded this from props and then never let props overwrite it export class InputArray extends React.Component<InputArrayProps, InputArrayState> {
// again (its getDerivedStateFromProps assigned the existing state back in static defaultProps = {
// both branches), so the value is owned by this component after mount. value: [],
const [value, setValue] = useState<(string | number | undefined)[]>(() => propsValue.slice(0)); default: [],
};
function isComplete(val: unknown[]) { constructor (props: InputArrayProps) {
return Array(props.length).fill(null).every((_, i) => { super(props);
const v = val[i]; this.state = {
return !(v === undefined || v === ""); value: this.props.value.slice(0),
// This is so we can compare changes in getDerivedStateFromProps
initialPropsValue: this.props.value.slice(0),
};
}
static getDerivedStateFromProps(props: Readonly<InputArrayProps>, state: InputArrayState) {
const value: any[] = [];
const initialPropsValue = state.initialPropsValue.slice(0);
Array(props.length).fill(null).map((_, i) => {
if (props.value[i] === state.initialPropsValue[i]) {
value[i] = state.value[i];
}
else {
value[i] = state.value[i];
initialPropsValue[i] = state.value[i];
}
});
return {
value,
initialPropsValue,
};
}
isComplete(value: unknown[]) {
return Array(this.props.length).fill(null).every((_, i) => {
const val = value[i];
return !(val === undefined || val === "");
}); });
} }
function changeValue(idx: number, newValue: string | number | undefined) { changeValue(idx: number, newValue: string | number | undefined) {
const nextValue = value.slice(0); const value = this.state.value.slice(0);
nextValue[idx] = newValue; value[idx] = newValue;
setValue(nextValue); this.setState({
value,
if (isComplete(nextValue) && props.onChange) { }, () => {
props.onChange(nextValue); if (this.isComplete(value) && this.props.onChange) {
} this.props.onChange(value);
else if (props.onChange) { }
// Unset until complete else if (this.props.onChange){
props.onChange(undefined); // Unset until complete
} this.props.onChange(undefined);
}
});
} }
const containsValues = ( render() {
value.length > 0 && const {value} = this.state;
!value.every(val => {
return (val === "" || val === undefined);
})
);
const inputs = Array(props.length).fill(null).map((_, i) => { const containsValues = (
if(props.type === "number") { value.length > 0 &&
return <InputNumber !value.every(val => {
key={i} return (val === "" || val === undefined);
default={containsValues || !props.default ? undefined : props.default[i] as number} })
value={value[i] as number} );
required={containsValues ? true : false}
onChange={(v) => changeValue(i, v)}
aria-label={props["aria-label"] || props.label}
/>;
} else {
return <InputString
key={i}
default={containsValues || !props.default ? undefined : props.default[i] as string}
value={value[i] as string}
required={containsValues ? true : false}
onChange={(v) => changeValue(i, v)}
aria-label={props["aria-label"] || props.label}
/>;
}
});
return ( const inputs = Array(this.props.length).fill(null).map((_, i) => {
<div className="maputnik-array"> if(this.props.type === "number") {
{inputs} return <InputNumber
</div> key={i}
); default={containsValues || !this.props.default ? undefined : this.props.default[i] as number}
}; value={value[i] as number}
required={containsValues ? true : false}
onChange={(v) => this.changeValue(i, v)}
aria-label={this.props["aria-label"] || this.props.label}
/>;
} else {
return <InputString
key={i}
default={containsValues || !this.props.default ? undefined : this.props.default[i] as string}
value={value[i] as string}
required={containsValues ? true : false}
onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label}
/>;
}
});
return (
<div className="maputnik-array">
{inputs}
</div>
);
}
}
+17 -15
View File
@@ -14,18 +14,20 @@ type InputButtonProps = {
title?: string title?: string
}; };
export const InputButton: React.FC<InputButtonProps> = (props) => { export class InputButton extends React.Component<InputButtonProps> {
return <button render() {
id={props.id} return <button
title={props.title} id={this.props.id}
type={props.type} title={this.props.title}
onClick={props.onClick} type={this.props.type}
disabled={props.disabled} onClick={this.props.onClick}
aria-label={props["aria-label"]} disabled={this.props.disabled}
className={classnames("maputnik-button", props.className)} aria-label={this.props["aria-label"]}
data-wd-key={props["data-wd-key"]} className={classnames("maputnik-button", this.props.className)}
style={props.style} data-wd-key={this.props["data-wd-key"]}
> style={this.props.style}
{props.children} >
</button>; {this.props.children}
}; </button>;
}
}
+27 -21
View File
@@ -6,26 +6,32 @@ export type InputCheckboxProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
}; };
export const InputCheckbox: React.FC<InputCheckboxProps> = ({value = false, ...props}) => { export class InputCheckbox extends React.Component<InputCheckboxProps> {
const onChange = () => { static defaultProps = {
props.onChange(!value); value: false,
}; };
return <div className="maputnik-checkbox-wrapper"> onChange = () => {
<input this.props.onChange(!this.props.value);
className="maputnik-checkbox" };
type="checkbox"
style={props.style} render() {
onChange={onChange} return <div className="maputnik-checkbox-wrapper">
onClick={onChange} <input
checked={value} className="maputnik-checkbox"
/> type="checkbox"
<div className="maputnik-checkbox-box"> style={this.props.style}
<svg style={{ onChange={this.onChange}
display: value ? "inline" : "none" onClick={this.onChange}
}} className="maputnik-checkbox-icon" viewBox='0 0 32 32'> checked={this.props.value}
<path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' /> />
</svg> <div className="maputnik-checkbox-box">
</div> <svg style={{
</div>; display: this.props.value ? "inline" : "none"
}; }} className="maputnik-checkbox-icon" viewBox='0 0 32 32'>
<path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' />
</svg>
</div>
</div>;
}
}
+77 -76
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React from "react";
import Color from "color"; import Color from "color";
import ChromePicker from "react-color/lib/components/chrome/Chrome"; import ChromePicker from "react-color/lib/components/chrome/Chrome";
import {type ColorResult} from "react-color"; import {type ColorResult} from "react-color";
@@ -20,27 +20,26 @@ export type InputColorProps = {
}; };
/*** Number fields with support for min, max and units and documentation*/ /*** Number fields with support for min, max and units and documentation*/
export const InputColor: React.FC<InputColorProps> = (props) => { export class InputColor extends React.Component<InputColorProps> {
const [pickerOpened, setPickerOpened] = useState(false); state = {
const colorInput = useRef<HTMLInputElement | null>(null); pickerOpened: false
};
colorInput: HTMLInputElement | null = null;
// Keep the latest `onChange` available to the throttled callback, which is constructor (props: InputColorProps) {
// created only once so that throttling actually takes effect. super(props);
const onChangeProp = useRef(props.onChange); this.onChangeNoCheck = lodash.throttle(this.onChangeNoCheck, 1000/30);
useEffect(() => { }
onChangeProp.current = props.onChange;
});
const onChangeNoCheck = useMemo( onChangeNoCheck(v: string) {
() => lodash.throttle((v: string) => onChangeProp.current(v), 1000/30), this.props.onChange(v);
[] }
);
//TODO: I much rather would do this with absolute positioning //TODO: I much rather would do this with absolute positioning
//but I am too stupid to get it to work together with fixed position //but I am too stupid to get it to work together with fixed position
//and scrollbars so I have to fallback to JavaScript //and scrollbars so I have to fallback to JavaScript
const calcPickerOffset = () => { calcPickerOffset = () => {
const elem = colorInput.current; const elem = this.colorInput;
if(elem) { if(elem) {
const pos = elem.getBoundingClientRect(); const pos = elem.getBoundingClientRect();
return { return {
@@ -55,80 +54,82 @@ export const InputColor: React.FC<InputColorProps> = (props) => {
} }
}; };
const togglePicker = () => { togglePicker = () => {
setPickerOpened(opened => !opened); this.setState({ pickerOpened: !this.state.pickerOpened });
}; };
const getColor = () => { get color() {
// Catch invalid color. // Catch invalid color.
try { try {
return Color(props.value).rgb(); return Color(this.props.value).rgb();
} }
catch(err) { catch(err) {
console.warn("Error parsing color: ", err); console.warn("Error parsing color: ", err);
return Color("rgb(255,255,255)"); return Color("rgb(255,255,255)");
} }
}; }
const onChange = (v: string) => { onChange (v: string) {
props.onChange(v === "" ? undefined : v); this.props.onChange(v === "" ? undefined : v);
}; }
const offset = calcPickerOffset(); render() {
const currentColor = getColor().object(); const offset = this.calcPickerOffset();
const currentChromeColor = { const currentColor = this.color.object();
r: currentColor.r, const currentChromeColor = {
g: currentColor.g, r: currentColor.r,
b: currentColor.b, g: currentColor.g,
// Rename alpha -> a for ChromePicker b: currentColor.b,
a: currentColor.alpha! // Rename alpha -> a for ChromePicker
}; a: currentColor.alpha!
};
const picker = <div const picker = <div
className="maputnik-color-picker-offset"
style={{
position: "fixed",
zIndex: 1,
left: offset.left,
top: offset.top,
}}>
<ChromePicker
color={currentChromeColor}
onChange={c => onChangeNoCheck(formatColor(c))}
/>
<div
className="maputnik-color-picker-offset" className="maputnik-color-picker-offset"
onClick={togglePicker}
style={{ style={{
zIndex: -1,
position: "fixed", position: "fixed",
top: "0px", zIndex: 1,
right: "0px", left: offset.left,
bottom: "0px", top: offset.top,
left: "0px", }}>
}} <ChromePicker
/> color={currentChromeColor}
</div>; onChange={c => this.onChangeNoCheck(formatColor(c))}
/>
<div
className="maputnik-color-picker-offset"
onClick={this.togglePicker}
style={{
zIndex: -1,
position: "fixed",
top: "0px",
right: "0px",
bottom: "0px",
left: "0px",
}}
/>
</div>;
const swatchStyle = { const swatchStyle = {
backgroundColor: props.value backgroundColor: this.props.value
}; };
return <div className="maputnik-color-wrapper"> return <div className="maputnik-color-wrapper">
{pickerOpened && picker} {this.state.pickerOpened && picker}
<div className="maputnik-color-swatch" style={swatchStyle}></div> <div className="maputnik-color-swatch" style={swatchStyle}></div>
<input <input
aria-label={props["aria-label"]} aria-label={this.props["aria-label"]}
spellCheck="false" spellCheck="false"
autoComplete="off" autoComplete="off"
className="maputnik-color" className="maputnik-color"
ref={colorInput} ref={(input) => {this.colorInput = input;}}
onClick={togglePicker} onClick={this.togglePicker}
style={props.style} style={this.props.style}
name={props.name} name={this.props.name}
placeholder={props.default} placeholder={this.props.default}
value={props.value ? props.value : ""} value={this.props.value ? this.props.value : ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => this.onChange(e.target.value)}
/> />
</div>; </div>;
}; }
}
+112 -106
View File
@@ -27,130 +27,136 @@ export type InputDynamicArrayProps = {
type InputDynamicArrayInternalProps = InputDynamicArrayProps & WithTranslation; type InputDynamicArrayInternalProps = InputDynamicArrayProps & WithTranslation;
const InputDynamicArrayInternal: React.FC<InputDynamicArrayInternalProps> = (props) => { class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInternalProps> {
const values = props.value || props.default || []; changeValue(idx: number, newValue: string | number | undefined) {
const values = this.values.slice(0);
values[idx] = newValue;
if (this.props.onChange) this.props.onChange(values);
}
const changeValue = (idx: number, newValue: string | number | undefined) => { get values() {
const newValues = values.slice(0); return this.props.value || this.props.default || [];
newValues[idx] = newValue; }
if (props.onChange) props.onChange(newValues);
};
const addValue = () => { addValue = () => {
const newValues = values.slice(0); const values = this.values.slice(0);
if (props.type === "number") { if (this.props.type === "number") {
newValues.push(0); values.push(0);
} }
else if (props.type === "url") { else if (this.props.type === "url") {
newValues.push(""); values.push("");
} }
else if (props.type === "enum") { else if (this.props.type === "enum") {
const {fieldSpec} = props; const {fieldSpec} = this.props;
const defaultValue = Object.keys(fieldSpec!.values)[0]; const defaultValue = Object.keys(fieldSpec!.values)[0];
newValues.push(defaultValue); values.push(defaultValue);
} else if (props.type === "color") { } else if (this.props.type === "color") {
newValues.push("#000000"); values.push("#000000");
} else { } else {
newValues.push(""); values.push("");
} }
if (props.onChange) props.onChange(newValues); if (this.props.onChange) this.props.onChange(values);
}; };
const deleteValue = (valueIdx: number) => { deleteValue(valueIdx: number) {
const newValues = values.slice(0); const values = this.values.slice(0);
newValues.splice(valueIdx, 1); values.splice(valueIdx, 1);
if (props.onChange) props.onChange(newValues.length > 0 ? newValues : undefined); if (this.props.onChange) this.props.onChange(values.length > 0 ? values : undefined);
}; }
const t = props.t; render() {
const i18nProps = { t, i18n: props.i18n, tReady: props.tReady }; const t = this.props.t;
const inputs = values.map((v, i) => { const i18nProps = { t, i18n: this.props.i18n, tReady: this.props.tReady };
const deleteValueBtn= <DeleteValueInputButton const inputs = this.values.map((v, i) => {
onClick={deleteValue.bind(null, i)} const deleteValueBtn= <DeleteValueInputButton
{...i18nProps} onClick={this.deleteValue.bind(this, i)}
/>; {...i18nProps}
let input;
if(props.type === "url") {
input = <InputUrl
value={v as string}
onChange={changeValue.bind(null, i)}
aria-label={props["aria-label"] || props.label}
/>; />;
} let input;
else if (props.type === "number") { if(this.props.type === "url") {
input = <InputNumber input = <InputUrl
value={v as number} value={v as string}
onChange={changeValue.bind(null, i)} onChange={this.changeValue.bind(this, i)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} }
else if (props.type === "enum") { else if (this.props.type === "number") {
const options = Object.keys(props.fieldSpec?.values).map(v => [v, capitalize(v)]); input = <InputNumber
input = <InputEnum value={v as number}
options={options} onChange={this.changeValue.bind(this, i)}
value={v as string} aria-label={this.props["aria-label"] || this.props.label}
onChange={changeValue.bind(null, i)} />;
aria-label={props["aria-label"] || props.label} }
/>; else if (this.props.type === "enum") {
} const options = Object.keys(this.props.fieldSpec?.values).map(v => [v, capitalize(v)]);
else if (props.type === "color") { input = <InputEnum
input = <InputColor options={options}
value={v as string} value={v as string}
onChange={changeValue.bind(null, i)} onChange={this.changeValue.bind(this, i)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} }
else { else if (this.props.type === "color") {
input = <InputString input = <InputColor
value={v as string} value={v as string}
onChange={changeValue.bind(null, i)} onChange={this.changeValue.bind(this, i)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} }
else {
input = <InputString
value={v as string}
onChange={this.changeValue.bind(this, i)}
aria-label={this.props["aria-label"] || this.props.label}
/>;
}
return <div return <div
style={props.style} style={this.props.style}
key={i} key={i}
className="maputnik-array-block" className="maputnik-array-block"
>
<div className="maputnik-array-block-action">
{deleteValueBtn}
</div>
<div className="maputnik-array-block-content">
{input}
</div>
</div>;
});
return (
<div className="maputnik-array">
{inputs}
<InputButton
className="maputnik-array-add-value"
onClick={addValue}
> >
{t("Add value")} <div className="maputnik-array-block-action">
</InputButton> {deleteValueBtn}
</div> </div>
); <div className="maputnik-array-block-content">
}; {input}
</div>
</div>;
});
return (
<div className="maputnik-array">
{inputs}
<InputButton
className="maputnik-array-add-value"
onClick={this.addValue}
>
{t("Add value")}
</InputButton>
</div>
);
}
}
export const InputDynamicArray = withTranslation()(InputDynamicArrayInternal); export const InputDynamicArray = withTranslation()(InputDynamicArrayInternal);
type DeleteValueInputButtonProps = { type DeleteValueInputButtonProps = {
onClick?(...args: unknown[]): unknown onClick?(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const DeleteValueInputButton: React.FC<DeleteValueInputButtonProps> = (props) => { class DeleteValueInputButton extends React.Component<DeleteValueInputButtonProps> {
const t = props.t; render() {
return <InputButton const t = this.props.t;
className="maputnik-delete-stop" return <InputButton
onClick={props.onClick} className="maputnik-delete-stop"
title={t("Remove array item")} onClick={this.props.onClick}
> title={t("Remove array item")}
<FieldDocLabel >
label={<MdDelete />} <FieldDocLabel
/> label={<MdDelete />}
</InputButton>; />
}; </InputButton>;
}
}
+20 -18
View File
@@ -25,23 +25,25 @@ export type InputEnumProps = {
}; };
export const InputEnum: React.FC<InputEnumProps> = (props) => { export class InputEnum extends React.Component<InputEnumProps> {
const {options, value, onChange, name, label} = props; render() {
const {options, value, onChange, name, label} = this.props;
if(options.length <= 3 && optionsLabelLength(options) <= 20) { if(options.length <= 3 && optionsLabelLength(options) <= 20) {
return <InputMultiInput return <InputMultiInput
name={name} name={name}
options={options} options={options}
value={(value || props.default)!} value={(value || this.props.default)!}
onChange={onChange} onChange={onChange}
aria-label={props["aria-label"] || label} aria-label={this.props["aria-label"] || label}
/>; />;
} else { } else {
return <InputSelect return <InputSelect
options={options} options={options}
value={(value || props.default)!} value={(value || this.props.default)!}
onChange={onChange} onChange={onChange}
aria-label={props["aria-label"] || label} aria-label={this.props["aria-label"] || label}
/>; />;
}
} }
}; }
+32 -28
View File
@@ -11,9 +11,13 @@ export type InputFontProps = {
"aria-label"?: string "aria-label"?: string
}; };
export const InputFont: React.FC<InputFontProps> = ({fonts = [], ...props}) => { export class InputFont extends React.Component<InputFontProps> {
const getValues = () => { static defaultProps = {
const out = props.value || props.default || []; fonts: []
};
get values() {
const out = this.props.value || this.props.default || [];
// Always put a "" in the last field to you can keep adding entries // Always put a "" in the last field to you can keep adding entries
if (out[out.length-1] !== ""){ if (out[out.length-1] !== ""){
@@ -22,36 +26,36 @@ export const InputFont: React.FC<InputFontProps> = ({fonts = [], ...props}) => {
else { else {
return out; return out;
} }
}; }
const values = getValues(); changeFont(idx: number, newValue: string) {
const changedValues = this.values.slice(0);
const changeFont = (idx: number, newValue: string) => {
const changedValues = values.slice(0);
changedValues[idx] = newValue; changedValues[idx] = newValue;
const filteredValues = changedValues const filteredValues = changedValues
.filter(v => v !== undefined) .filter(v => v !== undefined)
.filter(v => v !== ""); .filter(v => v !== "");
props.onChange(filteredValues); this.props.onChange(filteredValues);
}; }
const inputs = values.map((value, i) => { render() {
return <li const inputs = this.values.map((value, i) => {
key={i} return <li
> key={i}
<InputAutocomplete >
aria-label={props["aria-label"] || props.name} <InputAutocomplete
value={value} aria-label={this.props["aria-label"] || this.props.name}
options={fonts.map(f => [f, f])} value={value}
onChange={changeFont.bind(null, i)} options={this.props.fonts?.map(f => [f, f])}
/> onChange={this.changeFont.bind(this, i)}
</li>; />
}); </li>;
});
return ( return (
<ul className="maputnik-font"> <ul className="maputnik-font">
{inputs} {inputs}
</ul> </ul>
); );
}; }
}
+87 -88
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react"; import React from "react";
import classnames from "classnames"; import classnames from "classnames";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
@@ -25,49 +25,88 @@ export type InputJsonProps = {
}; };
type InputJsonInternalProps = InputJsonProps & WithTranslation; type InputJsonInternalProps = InputJsonProps & WithTranslation;
function getPrettyJson(data: any) { type InputJsonState = {
return stringifyPretty(data, {indent: 2, maxLength: 40}); isEditing: boolean
} prevValue: string
};
const InputJsonInternal: React.FC<InputJsonInternalProps> = ({ class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJsonState> {
value, static defaultProps = {
className, onFocus: () => {},
onChange, onBlur: () => {},
onFocus = () => {}, withScroll: false
onBlur = () => {}, };
lintType, _view: EditorView | undefined;
spec, _el: HTMLDivElement | null = null;
withScroll = false, _cancelNextChange: boolean = false;
}) => {
const el = useRef<HTMLDivElement | null>(null);
const view = useRef<EditorView | undefined>(undefined);
const cancelNextChange = useRef<boolean>(false);
// `isEditing` and `prevValue` are never rendered, they are only read from the
// CodeMirror callbacks, so refs keep them up to date without re-rendering.
const isEditing = useRef<boolean>(false);
const prevValue = useRef<string>(getPrettyJson(value));
// Mirrors `prevProps.value` from the previous `componentDidUpdate`.
const prevValueProp = useRef<object>(value);
const handleFocus = () => { constructor(props: InputJsonInternalProps) {
if (onFocus) onFocus(); super(props);
isEditing.current = true; this.state = {
isEditing: false,
prevValue: this.getPrettyJson(this.props.value),
};
}
getPrettyJson(data: any) {
return stringifyPretty(data, {indent: 2, maxLength: 40});
}
componentDidMount () {
this._view = createEditor({
parent: this._el!,
value: this.getPrettyJson(this.props.value),
lintType: this.props.lintType || "layer",
onChange: (value:string) => this.onChange(value),
onFocus: () => this.onFocus(),
onBlur: () => this.onBlur(),
spec: this.props.spec
});
}
onFocus = () => {
if (this.props.onFocus) this.props.onFocus();
this.setState({
isEditing: true,
});
}; };
const handleBlur = () => { onBlur = () => {
if (onBlur) onBlur(); if (this.props.onBlur) this.props.onBlur();
isEditing.current = false; this.setState({
isEditing: false,
});
}; };
const handleChange = () => { componentDidUpdate(prevProps: InputJsonProps) {
if (cancelNextChange.current) { if (!this.state.isEditing && prevProps.value !== this.props.value) {
cancelNextChange.current = false; this._cancelNextChange = true;
prevValue.current = view.current!.state.doc.toString(); const transactionSpec: TransactionSpec = {
changes: {
from: 0,
to: this._view!.state.doc.length,
insert: this.getPrettyJson(this.props.value)
}
};
if (this.props.withScroll) {
transactionSpec.selection = this._view!.state.selection;
transactionSpec.scrollIntoView = true;
}
this._view!.dispatch(transactionSpec);
}
}
onChange = (_e: unknown) => {
if (this._cancelNextChange) {
this._cancelNextChange = false;
this.setState({
prevValue: this._view!.state.doc.toString(),
});
return; return;
} }
const newCode = view.current!.state.doc.toString(); const newCode = this._view!.state.doc.toString();
if (prevValue.current !== newCode) { if (this.state.prevValue !== newCode) {
let parsedLayer, err; let parsedLayer, err;
try { try {
parsedLayer = JSON.parse(newCode); parsedLayer = JSON.parse(newCode);
@@ -77,63 +116,23 @@ const InputJsonInternal: React.FC<InputJsonInternalProps> = ({
} }
if (!err) { if (!err) {
if (onChange) onChange(parsedLayer); if (this.props.onChange) this.props.onChange(parsedLayer);
} }
} }
prevValue.current = newCode; this.setState({
prevValue: newCode,
});
}; };
// The editor is created once on mount, so its callbacks have to go through a render() {
// ref to always see the latest props. return <div className="json-editor" data-wd-key="json-editor" aria-hidden="true" style={{cursor: "text"}}>
const handlers = useRef({handleChange, handleFocus, handleBlur}); <div
useEffect(() => { className={classnames("codemirror-container", this.props.className)}
handlers.current = {handleChange, handleFocus, handleBlur}; ref={(el) => {this._el = el;}}
}); />
</div>;
useEffect(() => { }
view.current = createEditor({ }
parent: el.current!,
value: getPrettyJson(value),
lintType: lintType || "layer",
onChange: () => handlers.current.handleChange(),
onFocus: () => handlers.current.handleFocus(),
onBlur: () => handlers.current.handleBlur(),
spec: spec
});
// Runs once on mount, mirroring the previous componentDidMount.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only: adding deps would rebuild the editor on every change
}, []);
useEffect(() => {
// Only react to an actual change of the `value` prop, which also skips the
// initial mount (the editor is created with the value already).
if (prevValueProp.current === value) return;
prevValueProp.current = value;
if (isEditing.current) return;
cancelNextChange.current = true;
const transactionSpec: TransactionSpec = {
changes: {
from: 0,
to: view.current!.state.doc.length,
insert: getPrettyJson(value)
}
};
if (withScroll) {
transactionSpec.selection = view.current!.state.selection;
transactionSpec.scrollIntoView = true;
}
view.current!.dispatch(transactionSpec);
}, [value, withScroll]);
return <div className="json-editor" data-wd-key="json-editor" aria-hidden="true" style={{cursor: "text"}}>
<div
className={classnames("codemirror-container", className)}
ref={el}
/>
</div>;
};
export const InputJson = withTranslation()(InputJsonInternal); export const InputJson = withTranslation()(InputJsonInternal);
+27 -25
View File
@@ -9,29 +9,31 @@ export type InputMultiInputProps = {
"aria-label"?: string "aria-label"?: string
}; };
export const InputMultiInput: React.FC<InputMultiInputProps> = (props) => { export class InputMultiInput extends React.Component<InputMultiInputProps> {
let options = props.options; render() {
if(options.length > 0 && !Array.isArray(options[0])) { let options = this.props.options;
options = options.map(v => [v, v]); if(options.length > 0 && !Array.isArray(options[0])) {
options = options.map(v => [v, v]);
}
const selectedValue = this.props.value || options[0][0];
const radios = options.map(([val, label])=> {
return <label
key={val}
className={classnames("maputnik-button", "maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
>
<input type="radio"
name={this.props.name}
onChange={_e => this.props.onChange(val)}
value={val}
checked={val === selectedValue}
/>
{label}
</label>;
});
return <fieldset className="maputnik-multibutton" aria-label={this.props["aria-label"]}>
{radios}
</fieldset>;
} }
}
const selectedValue = props.value || options[0][0];
const radios = options.map(([val, label])=> {
return <label
key={val}
className={classnames("maputnik-button", "maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
>
<input type="radio"
name={props.name}
onChange={_e => props.onChange(val)}
value={val}
checked={val === selectedValue}
/>
{label}
</label>;
});
return <fieldset className="maputnik-multibutton" aria-label={props["aria-label"]}>
{radios}
</fieldset>;
};
+169 -141
View File
@@ -1,4 +1,5 @@
import React, { type BaseSyntheticEvent, useRef, useState } from "react"; import React, { type BaseSyntheticEvent } from "react";
import { generateUniqueId } from "../libs/document-uid";
export type InputNumberProps = { export type InputNumberProps = {
value?: number value?: number
@@ -13,206 +14,233 @@ export type InputNumberProps = {
"aria-label"?: string "aria-label"?: string
}; };
export const InputNumber: React.FC<InputNumberProps> = (props) => { type InputNumberState = {
const { rangeStep = 1 } = props; uuid: number
editing: boolean
editingRange?: boolean
value?: number
/**
* This is the value that is currently being edited. It can be an invalid value.
*/
dirtyValue?: number | string | undefined
};
const [editing, setEditing] = useState(false); export class InputNumber extends React.Component<InputNumberProps, InputNumberState> {
const [editingRange, setEditingRange] = useState(false); static defaultProps = {
const [value, setValue] = useState<number | undefined>(props.value); rangeStep: 1
/** The value currently being edited. It can be an invalid value. */ };
const [dirtyValue, setDirtyValue] = useState<number | string | undefined>(props.value); _keyboardEvent: boolean = false;
const keyboardEvent = useRef(false); constructor(props: InputNumberProps) {
super(props);
// Replaces getDerivedStateFromProps: while not editing, track the prop. this.state = {
if (!editing && props.value !== value) { uuid: +generateUniqueId(),
setValue(props.value); editing: false,
setDirtyValue(props.value); value: props.value,
dirtyValue: props.value,
};
} }
function isValid(v: number | string | undefined) { static getDerivedStateFromProps(props: Readonly<InputNumberProps>, state: InputNumberState) {
if (!state.editing && props.value !== state.value) {
return {
value: props.value,
dirtyValue: props.value,
};
}
return null;
}
changeValue(newValue: number | string | undefined) {
const value = (newValue === "" || newValue === undefined) ?
undefined : +newValue;
const hasChanged = this.props.value !== value;
if(this.isValid(value) && hasChanged) {
if (this.props.onChange) this.props.onChange(value);
this.setState({
value: value,
});
}
else if (!this.isValid(value) && hasChanged) {
this.setState({
value: undefined,
});
}
this.setState({
dirtyValue: newValue === "" ? undefined : newValue,
});
}
isValid(v: number | string | undefined) {
if (v === undefined) { if (v === undefined) {
return true; return true;
} }
const val = +v; const value = +v;
if(isNaN(val)) { if(isNaN(value)) {
return false; return false;
} }
if(!isNaN(props.min!) && val < props.min!) { if(!isNaN(this.props.min!) && value < this.props.min!) {
return false; return false;
} }
if(!isNaN(props.max!) && val > props.max!) { if(!isNaN(this.props.max!) && value > this.props.max!) {
return false; return false;
} }
return true; return true;
} }
function changeValue(newValue: number | string | undefined) { resetValue = () => {
const val = (newValue === "" || newValue === undefined) ? this.setState({editing: false});
undefined : +newValue;
const hasChanged = props.value !== val;
if(isValid(val) && hasChanged) {
if (props.onChange) props.onChange(val);
setValue(val);
}
else if (!isValid(val) && hasChanged) {
setValue(undefined);
}
setDirtyValue(newValue === "" ? undefined : newValue);
}
const resetValue = () => {
setEditing(false);
// Reset explicitly to default value if value has been cleared // Reset explicitly to default value if value has been cleared
if(!value) { if(!this.state.value) {
return; return;
} }
// If set value is invalid fall back to the last valid value from props or at last resort the default value // If set value is invalid fall back to the last valid value from props or at last resort the default value
if (!isValid(value)) { if (!this.isValid(this.state.value)) {
if(isValid(props.value)) { if(this.isValid(this.props.value)) {
changeValue(props.value); this.changeValue(this.props.value);
setDirtyValue(props.value); this.setState({dirtyValue: this.props.value});
} else { } else {
changeValue(undefined); this.changeValue(undefined);
setDirtyValue(undefined); this.setState({dirtyValue: undefined});
} }
} }
}; };
const onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => { onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
let newValue = parseFloat(e.target.value); let value = parseFloat(e.target.value);
const step = rangeStep; const step = this.props.rangeStep;
let newDirtyValue = newValue; let dirtyValue = value;
if(step) { if(step) {
// Can't do this with the <input/> range step attribute else we won't be able to set a high precision value via the text input. // Can't do this with the <input/> range step attribute else we won't be able to set a high precision value via the text input.
const snap = newValue % step; const snap = value % step;
// Round up/down to step // Round up/down to step
if (keyboardEvent.current) { if (this._keyboardEvent) {
// If it's keyboard event we might get a low positive/negative value, // If it's keyboard event we might get a low positive/negative value,
// for example we might go from 13 to 13.23, however because we know // for example we might go from 13 to 13.23, however because we know
// that came from a keyboard event we always want to increase by a // that came from a keyboard event we always want to increase by a
// single step value. // single step value.
if (newValue < +dirtyValue!) { if (value < +this.state.dirtyValue!) {
newValue = value! - step; value = this.state.value! - step;
} }
else { else {
newValue = value! + step; value = this.state.value! + step;
} }
newDirtyValue = newValue; dirtyValue = value;
} }
else { else {
if (snap < step/2) { if (snap < step/2) {
newValue = newValue - snap; value = value - snap;
} }
else { else {
newValue = newValue + (step - snap); value = value + (step - snap);
} }
} }
} }
keyboardEvent.current = false; this._keyboardEvent = false;
// Clamp between min/max // Clamp between min/max
newValue = Math.max(props.min!, Math.min(props.max!, newValue)); value = Math.max(this.props.min!, Math.min(this.props.max!, value));
setValue(newValue); this.setState({value, dirtyValue});
setDirtyValue(newDirtyValue); if (this.props.onChange) this.props.onChange(value);
if (props.onChange) props.onChange(newValue);
}; };
if( render() {
Object.prototype.hasOwnProperty.call(props, "min") && if(
Object.prototype.hasOwnProperty.call(props, "max") && Object.prototype.hasOwnProperty.call(this.props, "min") &&
props.min !== undefined && props.max !== undefined && Object.prototype.hasOwnProperty.call(this.props, "max") &&
props.allowRange this.props.min !== undefined && this.props.max !== undefined &&
) { this.props.allowRange
const currentValue = editing ? dirtyValue : value; ) {
const defaultValue = props.default === undefined ? "" : props.default; const value = this.state.editing ? this.state.dirtyValue : this.state.value;
let inputValue; const defaultValue = this.props.default === undefined ? "" : this.props.default;
if (editingRange) { let inputValue;
inputValue = value; if (this.state.editingRange) {
inputValue = this.state.value;
}
else {
inputValue = value;
}
return <div className="maputnik-number-container">
<input
className="maputnik-number-range"
key="range"
type="range"
max={this.props.max}
min={this.props.min}
step="any"
spellCheck="false"
value={value === undefined ? defaultValue : value}
onChange={this.onChangeRange}
onKeyDown={() => {
this._keyboardEvent = true;
}}
onPointerDown={() => {
this.setState({editing: true, editingRange: true});
}}
onPointerUp={() => {
// Safari doesn't get onBlur event
this.setState({editing: false, editingRange: false});
}}
onBlur={() => {
this.setState({
editing: false,
editingRange: false,
dirtyValue: this.state.value,
});
}}
data-wd-key={this.props["data-wd-key"] + "-range"}
/>
<input
key="text"
type="text"
spellCheck="false"
className="maputnik-number"
placeholder={this.props.default?.toString()}
value={inputValue === undefined ? "" : inputValue}
onFocus={_e => {
this.setState({editing: true});
}}
onChange={e => {
this.changeValue(e.target.value);
}}
onBlur={_e => {
this.setState({editing: false});
this.resetValue();
}}
data-wd-key={this.props["data-wd-key"] + "-text"}
/>
</div>;
} }
else { else {
inputValue = currentValue; const value = this.state.editing ? this.state.dirtyValue : this.state.value;
}
return <div className="maputnik-number-container"> return <input
<input aria-label={this.props["aria-label"]}
className="maputnik-number-range"
key="range"
type="range"
max={props.max}
min={props.min}
step="any"
spellCheck="false"
value={currentValue === undefined ? defaultValue : currentValue}
onChange={onChangeRange}
onKeyDown={() => {
keyboardEvent.current = true;
}}
onPointerDown={() => {
setEditing(true);
setEditingRange(true);
}}
onPointerUp={() => {
// Safari doesn't get onBlur event
setEditing(false);
setEditingRange(false);
}}
onBlur={() => {
setEditing(false);
setEditingRange(false);
setDirtyValue(value);
}}
data-wd-key={props["data-wd-key"] + "-range"}
/>
<input
key="text"
type="text"
spellCheck="false" spellCheck="false"
className="maputnik-number" className="maputnik-number"
placeholder={props.default?.toString()} placeholder={this.props.default?.toString()}
value={inputValue === undefined ? "" : inputValue} value={value === undefined ? "" : value}
onFocus={_e => { onChange={e => this.changeValue(e.target.value)}
setEditing(true); onFocus={() => {
this.setState({editing: true});
}} }}
onChange={e => { onBlur={this.resetValue}
changeValue(e.target.value); required={this.props.required}
}} data-wd-key={this.props["data-wd-key"]}
onBlur={_e => { />;
setEditing(false); }
resetValue();
}}
data-wd-key={props["data-wd-key"] + "-text"}
/>
</div>;
} }
else { }
const currentValue = editing ? dirtyValue : value;
return <input
aria-label={props["aria-label"]}
spellCheck="false"
className="maputnik-number"
placeholder={props.default?.toString()}
value={currentValue === undefined ? "" : currentValue}
onChange={e => changeValue(e.target.value)}
onFocus={() => {
setEditing(true);
}}
onBlur={resetValue}
required={props.required}
data-wd-key={props["data-wd-key"]}
/>;
}
};
+19 -17
View File
@@ -10,21 +10,23 @@ export type InputSelectProps = {
"aria-label"?: string "aria-label"?: string
}; };
export const InputSelect: React.FC<InputSelectProps> = (props) => { export class InputSelect extends React.Component<InputSelectProps> {
let options = props.options; render() {
if(options.length > 0 && !Array.isArray(options[0])) { let options = this.props.options;
options = options.map((v) => [v, v]) as [string, any][]; if(options.length > 0 && !Array.isArray(options[0])) {
} options = options.map((v) => [v, v]) as [string, any][];
}
return <select return <select
className="maputnik-select" className="maputnik-select"
data-wd-key={props["data-wd-key"]} data-wd-key={this.props["data-wd-key"]}
style={props.style} style={this.props.style}
title={props.title} title={this.props.title}
value={props.value} value={this.props.value}
onChange={e => props.onChange(e.target.value)} onChange={e => this.props.onChange(e.target.value)}
aria-label={props["aria-label"]} aria-label={this.props["aria-label"]}
> >
{ options.map(([val, label]) => <option key={val} value={val}>{label}</option>) } { options.map(([val, label]) => <option key={val} value={val}>{label}</option>) }
</select>; </select>;
}; }
}
+40 -38
View File
@@ -38,31 +38,31 @@ export type InputSpecProps = {
/** Display any field from the Maplibre GL style spec and /** Display any field from the Maplibre GL style spec and
* choose the correct field component based on the @{fieldSpec} * choose the correct field component based on the @{fieldSpec}
* to display @{value}. */ * to display @{value}. */
export const InputSpec: React.FC<InputSpecProps> = (props) => { export class InputSpec extends React.Component<InputSpecProps> {
const childNodes = () => { childNodes() {
const commonProps = { const commonProps = {
fieldSpec: props.fieldSpec, fieldSpec: this.props.fieldSpec,
label: props.label, label: this.props.label,
action: props.action, action: this.props.action,
style: props.style, style: this.props.style,
value: props.value, value: this.props.value,
default: props.fieldSpec?.default, default: this.props.fieldSpec?.default,
name: props.fieldName, name: this.props.fieldName,
"data-wd-key": "spec-field-input:" + props.fieldName, "data-wd-key": "spec-field-input:" + this.props.fieldName,
onChange: (newValue: number | undefined | (string | number | undefined)[]) => props.onChange!(props.fieldName, newValue), onChange: (newValue: number | undefined | (string | number | undefined)[]) => this.props.onChange!(this.props.fieldName, newValue),
"aria-label": props["aria-label"], "aria-label": this.props["aria-label"],
}; };
switch(props.fieldSpec?.type) { switch(this.props.fieldSpec?.type) {
case "number": return ( case "number": return (
<InputNumber <InputNumber
{...commonProps as InputNumberProps} {...commonProps as InputNumberProps}
min={props.fieldSpec.minimum} min={this.props.fieldSpec.minimum}
max={props.fieldSpec.maximum} max={this.props.fieldSpec.maximum}
/> />
); );
case "enum": { case "enum": {
const options = Object.keys(props.fieldSpec.values || []).map(v => [v, capitalize(v)]); const options = Object.keys(this.props.fieldSpec.values || []).map(v => [v, capitalize(v)]);
return <InputEnum return <InputEnum
{...commonProps as Omit<InputEnumProps, "options">} {...commonProps as Omit<InputEnumProps, "options">}
@@ -72,8 +72,8 @@ export const InputSpec: React.FC<InputSpecProps> = (props) => {
case "resolvedImage": case "resolvedImage":
case "formatted": case "formatted":
case "string": case "string":
if (iconProperties.indexOf(props.fieldName!) >= 0) { if (iconProperties.indexOf(this.props.fieldName!) >= 0) {
const options = props.fieldSpec.values || []; const options = this.props.fieldSpec.values || [];
return <InputAutocomplete return <InputAutocomplete
{...commonProps as Omit<InputAutocompleteProps, "options">} {...commonProps as Omit<InputAutocompleteProps, "options">}
options={options.map(f => [f, f])} options={options.map(f => [f, f])}
@@ -94,59 +94,61 @@ export const InputSpec: React.FC<InputSpecProps> = (props) => {
/> />
); );
case "array": case "array":
if(props.fieldName === "text-font") { if(this.props.fieldName === "text-font") {
return <InputFont return <InputFont
{...commonProps as InputFontProps} {...commonProps as InputFontProps}
fonts={props.fieldSpec.values} fonts={this.props.fieldSpec.values}
/>; />;
} else { } else {
if (props.fieldSpec.length) { if (this.props.fieldSpec.length) {
return <InputArray return <InputArray
{...commonProps as InputArrayProps} {...commonProps as InputArrayProps}
type={props.fieldSpec.value} type={this.props.fieldSpec.value}
length={props.fieldSpec.length} length={this.props.fieldSpec.length}
/>; />;
} else { } else {
return <InputDynamicArray return <InputDynamicArray
{...commonProps as InputDynamicArrayProps} {...commonProps as InputDynamicArrayProps}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
type={props.fieldSpec.value as InputDynamicArrayProps["type"]} type={this.props.fieldSpec.value as InputDynamicArrayProps["type"]}
/>; />;
} }
} }
case "numberArray": return ( case "numberArray": return (
<InputDynamicArray <InputDynamicArray
{...commonProps as InputDynamicArrayProps} {...commonProps as InputDynamicArrayProps}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
type="number" type="number"
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]} value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
/> />
); );
case "colorArray": return ( case "colorArray": return (
<InputDynamicArray <InputDynamicArray
{...commonProps as InputDynamicArrayProps} {...commonProps as InputDynamicArrayProps}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
type="color" type="color"
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]} value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
/> />
); );
case "padding": return ( case "padding": return (
<InputArray <InputArray
{...commonProps as InputArrayProps} {...commonProps as InputArrayProps}
type="number" type="number"
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]} value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
length={4} length={4}
/> />
); );
default: default:
console.warn(`No proper field input for ${props.fieldName} type: ${props.fieldSpec?.type}`); console.warn(`No proper field input for ${this.props.fieldName} type: ${this.props.fieldSpec?.type}`);
return null; return null;
} }
}; }
return ( render() {
<div data-wd-key={"spec-field:"+props.fieldName}> return (
{childNodes()} <div data-wd-key={"spec-field:"+this.props.fieldName}>
</div> {this.childNodes()}
); </div>
}; );
}
}
+82 -63
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
export type InputStringProps = { export type InputStringProps = {
"data-wd-key"?: string "data-wd-key"?: string
@@ -15,66 +15,85 @@ export type InputStringProps = {
title?: string title?: string
}; };
export const InputString: React.FC<InputStringProps> = (props) => { type InputStringState = {
const { onInput = () => {} } = props; editing: boolean
const [editing, setEditing] = useState(false); value?: string
const [value, setValue] = useState<string | undefined>(props.value);
// Replaces getDerivedStateFromProps: while the field is not being edited its
// value tracks the prop, so an external change to the style is picked up.
if (!editing && value !== props.value) {
setValue(props.value);
}
let tag;
let classes;
if(props.multi) {
tag = "textarea";
classes = [
"maputnik-string",
"maputnik-string--multi"
];
}
else {
tag = "input";
classes = [
"maputnik-string"
];
}
if(props.disabled) {
classes.push("maputnik-string--disabled");
}
return React.createElement(tag, {
"aria-label": props["aria-label"],
"data-wd-key": props["data-wd-key"],
spellCheck: Object.prototype.hasOwnProperty.call(props, "spellCheck") ? props.spellCheck : !(tag === "input"),
disabled: props.disabled,
className: classes.join(" "),
style: props.style,
value: value === undefined ? "" : value,
placeholder: props.default,
title: props.title,
onChange: (e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
setEditing(true);
setValue(e.target.value);
onInput(e.target.value);
},
onBlur: () => {
// Note: editing is only cleared when the value actually changed; this
// mirrors the original and keeps a no-op blur from resyncing the value.
if(value !== props.value) {
setEditing(false);
if (props.onChange) props.onChange(value);
}
},
onKeyDown: (e: React.KeyboardEvent) => {
if (e.keyCode === 13 && props.onChange) {
props.onChange(value);
}
},
required: props.required,
});
}; };
export class InputString extends React.Component<InputStringProps, InputStringState> {
static defaultProps = {
onInput: () => {},
};
constructor(props: InputStringProps) {
super(props);
this.state = {
editing: false,
value: props.value || ""
};
}
static getDerivedStateFromProps(props: Readonly<InputStringProps>, state: InputStringState) {
if (!state.editing) {
return {
value: props.value
};
}
return {};
}
render() {
let tag;
let classes;
if(this.props.multi) {
tag = "textarea";
classes = [
"maputnik-string",
"maputnik-string--multi"
];
}
else {
tag = "input";
classes = [
"maputnik-string"
];
}
if(this.props.disabled) {
classes.push("maputnik-string--disabled");
}
return React.createElement(tag, {
"aria-label": this.props["aria-label"],
"data-wd-key": this.props["data-wd-key"],
spellCheck: Object.prototype.hasOwnProperty.call(this.props, "spellCheck") ? this.props.spellCheck : !(tag === "input"),
disabled: this.props.disabled,
className: classes.join(" "),
style: this.props.style,
value: this.state.value === undefined ? "" : this.state.value,
placeholder: this.props.default,
title: this.props.title,
onChange: (e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
this.setState({
editing: true,
value: e.target.value
}, () => {
if (this.props.onInput) this.props.onInput(this.state.value);
});
},
onBlur: () => {
if(this.state.value!==this.props.value) {
this.setState({editing: false});
if (this.props.onChange) this.props.onChange(this.state.value);
}
},
onKeyDown: (e) => {
if (e.keyCode === 13 && this.props.onChange) {
this.props.onChange(this.state.value);
}
},
required: this.props.required,
});
}
}
+44 -25
View File
@@ -1,4 +1,4 @@
import React, { type JSX, useState } from "react"; import React, { type JSX } from "react";
import { InputString } from "./InputString"; import { InputString } from "./InputString";
import { SmallError } from "./SmallError"; import { SmallError } from "./SmallError";
import { Trans, type WithTranslation, withTranslation } from "react-i18next"; import { Trans, type WithTranslation, withTranslation } from "react-i18next";
@@ -46,30 +46,49 @@ export type FieldUrlProps = {
type InputUrlInternalProps = FieldUrlProps & WithTranslation; type InputUrlInternalProps = FieldUrlProps & WithTranslation;
const InputUrlInternal: React.FC<InputUrlInternalProps> = ({onInput = () => {}, ...props}) => { type InputUrlState = {
const [error, setError] = useState<ErrorType | undefined>(() => validate(props.value)); error?: ErrorType
const handleInput = (url: string) => {
setError(validate(url));
onInput(url);
};
const handleChange = (url: string) => {
setError(validate(url));
props.onChange(url);
};
return (
<div>
<InputString
{...props}
onInput={handleInput}
onChange={handleChange}
aria-label={props["aria-label"]}
/>
{errorTypeToJsx(error, props.t)}
</div>
);
}; };
class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlState> {
static defaultProps = {
onInput: () => {},
};
constructor (props: InputUrlInternalProps) {
super(props);
this.state = {
error: validate(props.value),
};
}
onInput = (url: string) => {
this.setState({
error: validate(url),
});
if (this.props.onInput) this.props.onInput(url);
};
onChange = (url: string) => {
this.setState({
error: validate(url),
});
this.props.onChange(url);
};
render () {
return (
<div>
<InputString
{...this.props}
onInput={this.onInput}
onChange={this.onChange}
aria-label={this.props["aria-label"]}
/>
{errorTypeToJsx(this.state.error, this.props.t)}
</div>
);
}
}
export const InputUrl = withTranslation()(InputUrlInternal); export const InputUrl = withTranslation()(InputUrlInternal);
+201 -190
View File
@@ -1,4 +1,4 @@
import React, { type JSX, useState } from "react"; import React, { type JSX } from "react";
import { Wrapper, Button, Menu, MenuItem } from "react-aria-menubutton"; import { Wrapper, Button, Menu, MenuItem } from "react-aria-menubutton";
import { Accordion } from "react-accessible-accordion"; import { Accordion } from "react-accessible-accordion";
import { MdMoreVert } from "react-icons/md"; import { MdMoreVert } from "react-icons/md";
@@ -17,9 +17,7 @@ import { FieldMaxZoom } from "./FieldMaxZoom";
import { FieldComment } from "./FieldComment"; import { FieldComment } from "./FieldComment";
import { FieldSource } from "./FieldSource"; import { FieldSource } from "./FieldSource";
import { FieldSourceLayer } from "./FieldSourceLayer"; import { FieldSourceLayer } from "./FieldSourceLayer";
// Aliased: the component defines its own changeProperty, which would otherwise import { changeType, changeProperty } from "../libs/layer";
// shadow this import (as a class method there was no collision).
import { changeType, changeProperty as changeLayerProperty } from "../libs/layer";
import { formatLayerId } from "../libs/format"; import { formatLayerId } from "../libs/format";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next"; import { type TFunction } from "i18next";
@@ -76,20 +74,20 @@ function getLayoutForType(type: LayerSpecification["type"], t: TFunction): Maput
return getLayoutForSymbolType(t); return getLayoutForSymbolType(t);
} }
const groups: MaputnikLayoutGroup[] = []; const groups: MaputnikLayoutGroup[] = [];
if (Object.keys(v8["paint_" + type]).length > 0) { if (Object.keys(v8["paint_" + type as keyof typeof v8]).length > 0) {
groups.push({ groups.push({
title: t("Paint properties"), title: t("Paint properties"),
id: "Paint_properties", id: "Paint_properties",
type: "properties", type: "properties",
fields: Object.keys(v8["paint_" + type]), fields: Object.keys(v8["paint_" + type as keyof typeof v8]),
}); });
} }
if (Object.keys(v8["layout_" + type]).length > 0) { if (Object.keys(v8["layout_" + type as keyof typeof v8]).length > 0) {
groups.push({ groups.push({
title: t("Layout properties"), title: t("Layout properties"),
id: "Layout_properties", id: "Layout_properties",
type: "properties", type: "properties",
fields: Object.keys(v8["layout_" + type]) fields: Object.keys(v8["layout_" + type as keyof typeof v8])
}); });
} }
return groups; return groups;
@@ -133,56 +131,67 @@ type LayerEditorInternalProps = {
errors?: MappedError[] errors?: MappedError[]
} & WithTranslation; } & WithTranslation;
type LayerEditorState = {
editorGroups: { [keys: string]: boolean }
};
/** Layer editor supporting multiple types of layers. */ /** Layer editor supporting multiple types of layers. */
const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, LayerEditorState> {
onLayerChanged = () => { }, static defaultProps = {
onLayerIdChange = () => { }, onLayerChanged: () => { },
...rest onLayerIdChange: () => { },
}) => { onLayerDestroyed: () => { },
const props = { onLayerChanged, onLayerIdChange, ...rest } as LayerEditorInternalProps; };
const [editorGroups, setEditorGroups] = useState<{ [keys: string]: boolean }>(() => { constructor(props: LayerEditorInternalProps) {
const groups: { [keys: string]: boolean } = {}; super(props);
for (const group of layoutGroups(props.layer.type, props.t)) {
groups[group.title] = true;
}
return groups;
});
// Replaces getDerivedStateFromProps: groups that appear after mount (because const editorGroups: { [keys: string]: boolean } = {};
// the layer type changed) start out expanded. Guarded so it only sets state for (const group of layoutGroups(this.props.layer.type, props.t)) {
// when a group is genuinely new, otherwise this would loop every render. editorGroups[group.title] = true;
const newGroups = getLayoutForType(props.layer.type, props.t)
.filter(group => !(group.title in editorGroups));
if (newGroups.length > 0) {
const additionalGroups = { ...editorGroups };
for (const group of newGroups) {
additionalGroups[group.title] = true;
} }
setEditorGroups(additionalGroups);
this.state = { editorGroups };
} }
function changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) { static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) {
props.onLayerChanged( const additionalGroups = { ...state.editorGroups };
props.layerIndex,
changeLayerProperty(props.layer, group, property, newValue) for (const group of getLayoutForType(props.layer.type, props.t)) {
if (!(group.title in additionalGroups)) {
additionalGroups[group.title] = true;
}
}
return {
editorGroups: additionalGroups
};
}
changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
this.props.onLayerChanged(
this.props.layerIndex,
changeProperty(this.props.layer, group, property, newValue)
); );
} }
function onGroupToggle(groupTitle: string, active: boolean) { onGroupToggle(groupTitle: string, active: boolean) {
const changedActiveGroups = { const changedActiveGroups = {
...editorGroups, ...this.state.editorGroups,
[groupTitle]: active, [groupTitle]: active,
}; };
setEditorGroups(changedActiveGroups); this.setState({
editorGroups: changedActiveGroups
});
} }
function renderGroupType(type: string, fields?: string[]): JSX.Element { renderGroupType(type: string, fields?: string[]): JSX.Element {
let comment = ""; let comment = "";
if (props.layer.metadata) { if (this.props.layer.metadata) {
comment = (props.layer.metadata as any)["maputnik:comment"]; comment = (this.props.layer.metadata as any)["maputnik:comment"];
} }
const { errors, layerIndex } = props; const { errors, layerIndex } = this.props;
const errorData: MappedLayerErrors = {}; const errorData: MappedLayerErrors = {};
errors!.forEach(error => { errors!.forEach(error => {
@@ -198,85 +207,85 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
}); });
let sourceLayerIds; let sourceLayerIds;
const layer = props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>; const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
if (Object.prototype.hasOwnProperty.call(props.sources, layer.source)) { if (Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) {
sourceLayerIds = props.sources[layer.source].layers; sourceLayerIds = this.props.sources[layer.source].layers;
} }
switch (type) { switch (type) {
case "layer": return <div> case "layer": return <div>
<FieldId <FieldId
value={props.layer.id} value={this.props.layer.id}
wdKey="layer-editor.layer-id" wdKey="layer-editor.layer-id"
error={errorData.id} error={errorData.id}
onChange={newId => props.onLayerIdChange(props.layerIndex, props.layer.id, newId)} onChange={newId => this.props.onLayerIdChange(this.props.layerIndex, this.props.layer.id, newId)}
/> />
<FieldType <FieldType
disabled={true} disabled={true}
error={errorData.type} error={errorData.type}
value={props.layer.type} value={this.props.layer.type}
onChange={newType => props.onLayerChanged( onChange={newType => this.props.onLayerChanged(
props.layerIndex, this.props.layerIndex,
changeType(props.layer, newType) changeType(this.props.layer, newType)
)} )}
/> />
{props.layer.type !== "background" && <FieldSource {this.props.layer.type !== "background" && <FieldSource
wdKey="layer-editor.layer-source" wdKey="layer-editor.layer-source"
error={errorData.source} error={errorData.source}
sourceIds={Object.keys(props.sources!)} sourceIds={Object.keys(this.props.sources!)}
value={props.layer.source} value={this.props.layer.source}
onChange={v => changeProperty(null, "source", v)} onChange={v => this.changeProperty(null, "source", v)}
/> />
} }
{!NON_SOURCE_LAYERS.includes(props.layer.type) && {!NON_SOURCE_LAYERS.includes(this.props.layer.type) &&
<FieldSourceLayer <FieldSourceLayer
error={errorData["source-layer"]} error={errorData["source-layer"]}
sourceLayerIds={sourceLayerIds} sourceLayerIds={sourceLayerIds}
value={(props.layer as any)["source-layer"]} value={(this.props.layer as any)["source-layer"]}
onChange={v => changeProperty(null, "source-layer", v)} onChange={v => this.changeProperty(null, "source-layer", v)}
/> />
} }
<FieldMinZoom <FieldMinZoom
error={errorData.minzoom} error={errorData.minzoom}
value={props.layer.minzoom} value={this.props.layer.minzoom}
onChange={v => changeProperty(null, "minzoom", v)} onChange={v => this.changeProperty(null, "minzoom", v)}
/> />
<FieldMaxZoom <FieldMaxZoom
error={errorData.maxzoom} error={errorData.maxzoom}
value={props.layer.maxzoom} value={this.props.layer.maxzoom}
onChange={v => changeProperty(null, "maxzoom", v)} onChange={v => this.changeProperty(null, "maxzoom", v)}
/> />
<FieldComment <FieldComment
error={errorData.comment} error={errorData.comment}
value={comment} value={comment}
onChange={v => changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)} onChange={v => this.changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)}
/> />
</div>; </div>;
case "filter": return <div> case "filter": return <div>
<div className="maputnik-filter-editor-wrapper"> <div className="maputnik-filter-editor-wrapper">
<FilterEditor <FilterEditor
errors={errorData} errors={errorData}
filter={(props.layer as any).filter} filter={(this.props.layer as any).filter}
properties={props.vectorLayers[(props.layer as any)["source-layer"]]} properties={this.props.vectorLayers[(this.props.layer as any)["source-layer"]]}
onChange={f => changeProperty(null, "filter", f)} onChange={f => this.changeProperty(null, "filter", f)}
/> />
</div> </div>
</div>; </div>;
case "properties": case "properties":
return <PropertyGroup return <PropertyGroup
errors={errorData} errors={errorData}
layer={props.layer} layer={this.props.layer}
groupFields={fields!} groupFields={fields!}
spec={props.spec} spec={this.props.spec}
onChange={changeProperty.bind(null)} onChange={this.changeProperty.bind(this)}
/>; />;
case "jsoneditor": case "jsoneditor":
return <FieldJson return <FieldJson
lintType="layer" lintType="layer"
value={props.layer} value={this.props.layer}
onChange={(layer: LayerSpecification) => { onChange={(layer: LayerSpecification) => {
props.onLayerChanged( this.props.onLayerChanged(
props.layerIndex, this.props.layerIndex,
layer layer
); );
}} }}
@@ -285,128 +294,130 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
} }
} }
function moveLayer(offset: number) { moveLayer(offset: number) {
props.onMoveLayer({ this.props.onMoveLayer({
oldIndex: props.layerIndex, oldIndex: this.props.layerIndex,
newIndex: props.layerIndex + offset newIndex: this.props.layerIndex + offset
}); });
} }
const t = props.t; render() {
const t = this.props.t;
const groupIds: string[] = []; const groupIds: string[] = [];
const layerType = props.layer.type; const layerType = this.props.layer.type;
const groups = layoutGroups(layerType, t).filter(group => { const groups = layoutGroups(layerType, t).filter(group => {
return !(layerType === "background" && group.type === "source"); return !(layerType === "background" && group.type === "source");
}).map(group => { }).map(group => {
const groupId = group.id; const groupId = group.id;
groupIds.push(groupId); groupIds.push(groupId);
return <LayerEditorGroup return <LayerEditorGroup
data-wd-key={group.title} data-wd-key={group.title}
id={groupId} id={groupId}
key={groupId} key={groupId}
title={group.title} title={group.title}
isActive={editorGroups[group.title]} isActive={this.state.editorGroups[group.title]}
onActiveToggle={onGroupToggle.bind(null, group.title)} onActiveToggle={this.onGroupToggle.bind(this, group.title)}
>
{renderGroupType(group.type, group.fields)}
</LayerEditorGroup>;
});
const layout = props.layer.layout || {};
const items: {
[key: string]: {
text: string,
handler: () => void,
disabled?: boolean,
wdKey?: string
}
} = {
delete: {
text: t("Delete"),
handler: () => props.onLayerDestroy(props.layerIndex),
wdKey: "menu-delete-layer"
},
duplicate: {
text: t("Duplicate"),
handler: () => props.onLayerCopy(props.layerIndex),
wdKey: "menu-duplicate-layer"
},
hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => props.onLayerVisibilityToggle(props.layerIndex),
wdKey: "menu-hide-layer"
},
moveLayerUp: {
text: t("Move layer up"),
disabled: props.isFirstLayer,
handler: () => moveLayer(-1),
wdKey: "menu-move-layer-up"
},
moveLayerDown: {
text: t("Move layer down"),
disabled: props.isLastLayer,
handler: () => moveLayer(+1),
wdKey: "menu-move-layer-down"
}
};
function handleSelection(id: string, event: React.SyntheticEvent) {
event.stopPropagation();
items[id].handler();
}
return <IconContext.Provider value={{ size: "14px", color: "#8e8e8e" }}>
<section className="maputnik-layer-editor"
role="main"
aria-label={t("Layer editor")}
data-wd-key="layer-editor"
>
<header data-wd-key="layer-editor.header">
<div className="layer-header">
<h2 className="layer-header__title">
{t("Layer")}: {formatLayerId(props.layer.id)}
</h2>
<div className="layer-header__info">
<Wrapper
className='more-menu'
onSelection={(id, event) => handleSelection(id as string, event)}
closeOnSelection={false}
>
<Button
id="skip-target-layer-editor"
data-wd-key="skip-target-layer-editor"
className='more-menu__button'
title={"Layer options"}>
<MdMoreVert className="more-menu__button__svg" />
</Button>
<Menu>
<ul className="more-menu__menu">
{Object.keys(items).map((id) => {
const item = items[id];
return <li key={id}>
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}>
{item.text}
</MenuItem>
</li>;
})}
</ul>
</Menu>
</Wrapper>
</div>
</div>
</header>
<Accordion
allowMultipleExpanded={true}
allowZeroExpanded={true}
preExpanded={groupIds}
> >
{groups} {this.renderGroupType(group.type, group.fields)}
</Accordion> </LayerEditorGroup>;
</section> });
</IconContext.Provider>;
}; const layout = this.props.layer.layout || {};
const items: {
[key: string]: {
text: string,
handler: () => void,
disabled?: boolean,
wdKey?: string
}
} = {
delete: {
text: t("Delete"),
handler: () => this.props.onLayerDestroy(this.props.layerIndex),
wdKey: "menu-delete-layer"
},
duplicate: {
text: t("Duplicate"),
handler: () => this.props.onLayerCopy(this.props.layerIndex),
wdKey: "menu-duplicate-layer"
},
hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex),
wdKey: "menu-hide-layer"
},
moveLayerUp: {
text: t("Move layer up"),
disabled: this.props.isFirstLayer,
handler: () => this.moveLayer(-1),
wdKey: "menu-move-layer-up"
},
moveLayerDown: {
text: t("Move layer down"),
disabled: this.props.isLastLayer,
handler: () => this.moveLayer(+1),
wdKey: "menu-move-layer-down"
}
};
function handleSelection(id: string, event: React.SyntheticEvent) {
event.stopPropagation();
items[id].handler();
}
return <IconContext.Provider value={{ size: "14px", color: "#8e8e8e" }}>
<section className="maputnik-layer-editor"
role="main"
aria-label={t("Layer editor")}
data-wd-key="layer-editor"
>
<header data-wd-key="layer-editor.header">
<div className="layer-header">
<h2 className="layer-header__title">
{t("Layer")}: {formatLayerId(this.props.layer.id)}
</h2>
<div className="layer-header__info">
<Wrapper
className='more-menu'
onSelection={(id, event) => handleSelection(id as string, event)}
closeOnSelection={false}
>
<Button
id="skip-target-layer-editor"
data-wd-key="skip-target-layer-editor"
className='more-menu__button'
title={"Layer options"}>
<MdMoreVert className="more-menu__button__svg" />
</Button>
<Menu>
<ul className="more-menu__menu">
{Object.keys(items).map((id) => {
const item = items[id];
return <li key={id}>
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}>
{item.text}
</MenuItem>
</li>;
})}
</ul>
</Menu>
</Wrapper>
</div>
</div>
</header>
<Accordion
allowMultipleExpanded={true}
allowZeroExpanded={true}
preExpanded={groupIds}
>
{groups}
</Accordion>
</section>
</IconContext.Provider>;
}
}
export const LayerEditor = withTranslation()(LayerEditorInternal); export const LayerEditor = withTranslation()(LayerEditorInternal);
+19 -17
View File
@@ -18,20 +18,22 @@ type LayerEditorGroupProps = {
}; };
export const LayerEditorGroup: React.FC<LayerEditorGroupProps> = (props) => { export class LayerEditorGroup extends React.Component<LayerEditorGroupProps> {
return <AccordionItem uuid={props.id}> render() {
<AccordionItemHeading className="maputnik-layer-editor-group" return <AccordionItem uuid={this.props.id}>
data-wd-key={"layer-editor-group:"+props["data-wd-key"]} <AccordionItemHeading className="maputnik-layer-editor-group"
onClick={_e => props.onActiveToggle(!props.isActive)} data-wd-key={"layer-editor-group:"+this.props["data-wd-key"]}
> onClick={_e => this.props.onActiveToggle(!this.props.isActive)}
<AccordionItemButton className="maputnik-layer-editor-group__button"> >
<span style={{flexGrow: 1, alignContent: "center"}}>{props.title}</span> <AccordionItemButton className="maputnik-layer-editor-group__button">
<MdArrowDropUp size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--up"></MdArrowDropUp> <span style={{flexGrow: 1, alignContent: "center"}}>{this.props.title}</span>
<MdArrowDropDown size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"></MdArrowDropDown> <MdArrowDropUp size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--up"></MdArrowDropUp>
</AccordionItemButton> <MdArrowDropDown size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"></MdArrowDropDown>
</AccordionItemHeading> </AccordionItemButton>
<AccordionItemPanel> </AccordionItemHeading>
{props.children} <AccordionItemPanel>
</AccordionItemPanel> {this.props.children}
</AccordionItem>; </AccordionItemPanel>
}; </AccordionItem>;
}
}
+229 -218
View File
@@ -1,4 +1,4 @@
import React, {type JSX, useEffect, useRef, useState} from "react"; import React, {type JSX} from "react";
import classnames from "classnames"; import classnames from "classnames";
import lodash from "lodash"; import lodash from "lodash";
import { import {
@@ -37,98 +37,62 @@ type LayerListContainerProps = {
}; };
type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation; type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation;
const noopLayerSelect = () => {}; type LayerListContainerState = {
collapsedGroups: {[ket: string]: boolean}
// Replaces the previous `shouldComponentUpdate`. Note the inversion: this areAllGroupsExpanded: boolean
// returns true when the props are EQUAL (i.e. no re-render is needed), whereas keys: {[key: string]: number}
// `shouldComponentUpdate` returned true when a re-render WAS needed. isOpen: {[key: string]: boolean}
function arePropsEqual(prevProps: LayerListContainerInternalProps, nextProps: LayerListContainerInternalProps) { };
// This component tree only requires id and visibility from the layers
// objects
function getRequiredProps(layer: LayerSpecification) {
const out: {id: string, layout?: { visibility: any}} = {
id: layer.id,
};
if (layer.layout) {
out.layout = {
visibility: layer.layout.visibility
};
}
return out;
}
const layersEqual = lodash.isEqual(
nextProps.layers.map(getRequiredProps),
prevProps.layers.map(getRequiredProps),
);
function withoutLayers(props: LayerListContainerInternalProps) {
const out = {
...props
} as LayerListContainerInternalProps & { layers?: any };
delete out["layers"];
return out;
}
// Compare the props without layers because we've already compared them
// efficiently above.
const propsEqual = lodash.isEqual(
withoutLayers(prevProps),
withoutLayers(nextProps)
);
return layersEqual && propsEqual;
}
// List of collapsible layer editors // List of collapsible layer editors
function LayerListContainerInternal({ class LayerListContainerInternal extends React.Component<LayerListContainerInternalProps, LayerListContainerState> {
layers: propsLayers, static defaultProps = {
selectedLayerIndex, onLayerSelect: () => {},
onLayersChange, };
onLayerSelect = noopLayerSelect, selectedItemRef: React.RefObject<any>;
onLayerDestroy, scrollContainerRef: React.RefObject<HTMLElement | null>;
onLayerCopy,
onLayerVisibilityToggle,
sources,
errors,
t,
}: LayerListContainerInternalProps) {
const selectedItemRef = useRef<any>(null);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const hasMountedRef = useRef(false);
const [collapsedGroups, setCollapsedGroups] = useState<{[key: string]: boolean}>({}); constructor(props: LayerListContainerInternalProps) {
const [areAllGroupsExpanded, setAreAllGroupsExpanded] = useState(false); super(props);
const [keys, setKeys] = useState<{[key: string]: number}>(() => ({ this.selectedItemRef = React.createRef();
add: +generateUniqueId(), this.scrollContainerRef = React.createRef();
})); this.state = {
const [isOpen, setIsOpen] = useState<{[key: string]: boolean}>({ collapsedGroups: {},
add: false, areAllGroupsExpanded: false,
}); keys: {
add: +generateUniqueId(),
function toggleModal(modalName: string) { },
setKeys(prevKeys => ({ isOpen: {
...prevKeys, add: false,
[modalName]: +generateUniqueId(), }
})); };
setIsOpen(prevIsOpen => ({
...prevIsOpen,
[modalName]: !prevIsOpen[modalName]
}));
} }
const toggleLayers = () => { toggleModal(modalName: string) {
this.setState({
keys: {
...this.state.keys,
[modalName]: +generateUniqueId(),
},
isOpen: {
...this.state.isOpen,
[modalName]: !this.state.isOpen[modalName]
}
});
}
toggleLayers = () => {
let idx = 0; let idx = 0;
const newGroups: {[key:string]: boolean} = {}; const newGroups: {[key:string]: boolean} = {};
groupedLayers().forEach(layers => { this.groupedLayers().forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id); const groupPrefix = layerPrefix(layers[0].id);
const lookupKey = [groupPrefix, idx].join("-"); const lookupKey = [groupPrefix, idx].join("-");
if (layers.length > 1) { if (layers.length > 1) {
newGroups[lookupKey] = areAllGroupsExpanded; newGroups[lookupKey] = this.state.areAllGroupsExpanded;
} }
layers.forEach((_layer) => { layers.forEach((_layer) => {
@@ -136,17 +100,19 @@ function LayerListContainerInternal({
}); });
}); });
setCollapsedGroups(newGroups); this.setState({
setAreAllGroupsExpanded(!areAllGroupsExpanded); collapsedGroups: newGroups,
areAllGroupsExpanded: !this.state.areAllGroupsExpanded
});
}; };
function groupedLayers(): (LayerSpecification & {key: string})[][] { groupedLayers(): (LayerSpecification & {key: string})[][] {
const groups = []; const groups = [];
const layerIdCount = new Map(); const layerIdCount = new Map();
for (let i = 0; i < propsLayers.length; i++) { for (let i = 0; i < this.props.layers.length; i++) {
const origLayer = propsLayers[i]; const origLayer = this.props.layers[i];
const previousLayer = propsLayers[i-1]; const previousLayer = this.props.layers[i-1];
layerIdCount.set(origLayer.id, layerIdCount.set(origLayer.id,
layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0 layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0
); );
@@ -164,162 +130,207 @@ function LayerListContainerInternal({
return groups; return groups;
} }
function toggleLayerGroup(groupPrefix: string, idx: number) { toggleLayerGroup(groupPrefix: string, idx: number) {
const lookupKey = [groupPrefix, idx].join("-"); const lookupKey = [groupPrefix, idx].join("-");
setCollapsedGroups(prevCollapsedGroups => { const newGroups = { ...this.state.collapsedGroups };
const newGroups = { ...prevCollapsedGroups }; if(lookupKey in this.state.collapsedGroups) {
if(lookupKey in prevCollapsedGroups) { newGroups[lookupKey] = !this.state.collapsedGroups[lookupKey];
newGroups[lookupKey] = !prevCollapsedGroups[lookupKey]; } else {
} else { newGroups[lookupKey] = false;
newGroups[lookupKey] = false; }
} this.setState({
return newGroups; collapsedGroups: newGroups
}); });
} }
function isCollapsed(groupPrefix: string, idx: number) { isCollapsed(groupPrefix: string, idx: number) {
const collapsed = collapsedGroups[[groupPrefix, idx].join("-")]; const collapsed = this.state.collapsedGroups[[groupPrefix, idx].join("-")];
return collapsed === undefined ? true : collapsed; return collapsed === undefined ? true : collapsed;
} }
useEffect(() => { shouldComponentUpdate (nextProps: LayerListContainerProps, nextState: LayerListContainerState) {
// `componentDidUpdate` did not run on mount, so skip the first run here too. // Always update on state change
if (!hasMountedRef.current) { if (this.state !== nextState) {
hasMountedRef.current = true; return true;
return;
} }
const selectedItemNode = selectedItemRef.current;
if (selectedItemNode && selectedItemNode.node) { // This component tree only requires id and visibility from the layers
const target = selectedItemNode.node; // objects
const options = { function getRequiredProps(layer: LayerSpecification) {
root: scrollContainerRef.current, const out: {id: string, layout?: { visibility: any}} = {
threshold: 1.0 id: layer.id,
}; };
const observer = new IntersectionObserver(entries => {
observer.unobserve(target);
if (entries.length > 0 && entries[0].intersectionRatio < 1) {
target.scrollIntoView();
}
}, options);
observer.observe(target); if (layer.layout) {
out.layout = {
visibility: layer.layout.visibility
};
}
return out;
} }
}, [selectedLayerIndex]); const layersEqual = lodash.isEqual(
nextProps.layers.map(getRequiredProps),
this.props.layers.map(getRequiredProps),
);
const listItems: JSX.Element[] = []; function withoutLayers(props: LayerListContainerProps) {
let idx = 0; const out = {
const layersByGroup = groupedLayers(); ...props
layersByGroup.forEach(layers => { } as LayerListContainerProps & { layers?: any };
const groupPrefix = layerPrefix(layers[0].id); delete out["layers"];
if(layers.length > 1) { return out;
const currentIdx = idx;
const grp = <LayerListGroup
data-wd-key={[groupPrefix, idx].join("-")}
aria-controls={layers.map(l => l.key).join(" ")}
key={`group-${groupPrefix}-${idx}`}
title={groupPrefix}
isActive={!isCollapsed(groupPrefix, idx) || idx === selectedLayerIndex}
onActiveToggle={() => toggleLayerGroup(groupPrefix, currentIdx)}
/>;
listItems.push(grp);
} }
layers.forEach((layer, idxInGroup) => { // Compare the props without layers because we've already compared them
const groupIdx = findClosestCommonPrefix(propsLayers, idx); // efficiently above.
const propsEqual = lodash.isEqual(
withoutLayers(this.props),
withoutLayers(nextProps)
);
const layerError = errors.find(error => { const propsChanged = !(layersEqual && propsEqual);
return ( return propsChanged;
error.parsed && }
error.parsed.type === "layer" &&
error.parsed.data.index == idx
);
});
const additionalProps: {ref?: React.RefObject<any>} = {}; componentDidUpdate (prevProps: LayerListContainerProps) {
if (idx === selectedLayerIndex) { if (prevProps.selectedLayerIndex !== this.props.selectedLayerIndex) {
additionalProps.ref = selectedItemRef; const selectedItemNode = this.selectedItemRef.current;
if (selectedItemNode && selectedItemNode.node) {
const target = selectedItemNode.node;
const options = {
root: this.scrollContainerRef.current,
threshold: 1.0
};
const observer = new IntersectionObserver(entries => {
observer.unobserve(target);
if (entries.length > 0 && entries[0].intersectionRatio < 1) {
target.scrollIntoView();
}
}, options);
observer.observe(target);
}
}
}
render() {
const listItems: JSX.Element[] = [];
let idx = 0;
const layersByGroup = this.groupedLayers();
layersByGroup.forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id);
if(layers.length > 1) {
const grp = <LayerListGroup
data-wd-key={[groupPrefix, idx].join("-")}
aria-controls={layers.map(l => l.key).join(" ")}
key={`group-${groupPrefix}-${idx}`}
title={groupPrefix}
isActive={!this.isCollapsed(groupPrefix, idx) || idx === this.props.selectedLayerIndex}
onActiveToggle={this.toggleLayerGroup.bind(this, groupPrefix, idx)}
/>;
listItems.push(grp);
} }
const listItem = <LayerListItem layers.forEach((layer, idxInGroup) => {
className={classnames({ const groupIdx = findClosestCommonPrefix(this.props.layers, idx);
"maputnik-layer-list-item-collapsed": layers.length > 1 && isCollapsed(groupPrefix, groupIdx) && idx !== selectedLayerIndex,
"maputnik-layer-list-item-group-last": idxInGroup == layers.length - 1 && layers.length > 1,
"maputnik-layer-list-item--error": !!layerError
})}
key={layer.key}
id={layer.key}
layerId={layer.id}
layerIndex={idx}
layerType={layer.type}
visibility={(layer.layout || {}).visibility}
isSelected={idx === selectedLayerIndex}
onLayerSelect={onLayerSelect}
onLayerDestroy={onLayerDestroy}
onLayerCopy={onLayerCopy}
onLayerVisibilityToggle={onLayerVisibilityToggle}
{...additionalProps}
/>;
listItems.push(listItem);
idx += 1;
});
});
return <section const layerError = this.props.errors.find(error => {
className="maputnik-layer-list" return (
data-wd-key="layer-list" error.parsed &&
role="complementary" error.parsed.type === "layer" &&
aria-label={t("Layers list")} error.parsed.data.index == idx
ref={scrollContainerRef} );
> });
<ModalAdd
key={keys.add} const additionalProps: {ref?: React.RefObject<any>} = {};
layers={propsLayers} if (idx === this.props.selectedLayerIndex) {
sources={sources} additionalProps.ref = this.selectedItemRef;
isOpen={isOpen.add} }
onOpenToggle={() => toggleModal("add")}
onLayersChange={onLayersChange} const listItem = <LayerListItem
/> className={classnames({
<header className="maputnik-layer-list-header" data-wd-key="layer-list.header"> "maputnik-layer-list-item-collapsed": layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex,
<span className="maputnik-layer-list-header-title">{t("Layers")}</span> "maputnik-layer-list-item-group-last": idxInGroup == layers.length - 1 && layers.length > 1,
<span className="maputnik-space" /> "maputnik-layer-list-item--error": !!layerError
<div className="maputnik-default-property"> })}
<div className="maputnik-multibutton"> key={layer.key}
<button id={layer.key}
id="skip-target-layer-list" layerId={layer.id}
data-wd-key="skip-target-layer-list" layerIndex={idx}
onClick={toggleLayers} layerType={layer.type}
className="maputnik-button"> visibility={(layer.layout || {}).visibility}
{areAllGroupsExpanded === true ? isSelected={idx === this.props.selectedLayerIndex}
t("Collapse") onLayerSelect={this.props.onLayerSelect}
: onLayerDestroy={this.props.onLayerDestroy?.bind(this)}
t("Expand") onLayerCopy={this.props.onLayerCopy.bind(this)}
} onLayerVisibilityToggle={this.props.onLayerVisibilityToggle.bind(this)}
</button> {...additionalProps}
</div> />;
</div> listItems.push(listItem);
<div className="maputnik-default-property"> idx += 1;
<div className="maputnik-multibutton"> });
<button });
onClick={() => toggleModal("add")}
data-wd-key="layer-list:add-layer" const t = this.props.t;
className="maputnik-button maputnik-button-selected">
{t("Add Layer")} return <section
</button> className="maputnik-layer-list"
</div> data-wd-key="layer-list"
</div> role="complementary"
</header>
<div
role="navigation"
aria-label={t("Layers list")} aria-label={t("Layers list")}
ref={this.scrollContainerRef}
> >
<ul className="maputnik-layer-list-container"> <ModalAdd
{listItems} key={this.state.keys.add}
</ul> layers={this.props.layers}
</div> sources={this.props.sources}
</section>; isOpen={this.state.isOpen.add}
onOpenToggle={this.toggleModal.bind(this, "add")}
onLayersChange={this.props.onLayersChange}
/>
<header className="maputnik-layer-list-header" data-wd-key="layer-list.header">
<span className="maputnik-layer-list-header-title">{t("Layers")}</span>
<span className="maputnik-space" />
<div className="maputnik-default-property">
<div className="maputnik-multibutton">
<button
id="skip-target-layer-list"
data-wd-key="skip-target-layer-list"
onClick={this.toggleLayers}
className="maputnik-button">
{this.state.areAllGroupsExpanded === true ?
t("Collapse")
:
t("Expand")
}
</button>
</div>
</div>
<div className="maputnik-default-property">
<div className="maputnik-multibutton">
<button
onClick={this.toggleModal.bind(this, "add")}
data-wd-key="layer-list:add-layer"
className="maputnik-button maputnik-button-selected">
{t("Add Layer")}
</button>
</div>
</div>
</header>
<div
role="navigation"
aria-label={t("Layers list")}
>
<ul className="maputnik-layer-list-container">
{listItems}
</ul>
</div>
</section>;
}
} }
const LayerListContainer = withTranslation()(React.memo(LayerListContainerInternal, arePropsEqual)); const LayerListContainer = withTranslation()(LayerListContainerInternal);
type LayerListProps = LayerListContainerProps & { type LayerListProps = LayerListContainerProps & {
onMoveLayer: OnMoveLayerCallback onMoveLayer: OnMoveLayerCallback
+22 -20
View File
@@ -9,24 +9,26 @@ type LayerListGroupProps = {
"aria-controls"?: string "aria-controls"?: string
}; };
export const LayerListGroup: React.FC<LayerListGroupProps> = (props) => { export class LayerListGroup extends React.Component<LayerListGroupProps> {
return <li className="maputnik-layer-list-group"> render() {
<div className="maputnik-layer-list-group-header" return <li className="maputnik-layer-list-group">
data-wd-key={"layer-list-group:"+props["data-wd-key"]} <div className="maputnik-layer-list-group-header"
onClick={_e => props.onActiveToggle(!props.isActive)} data-wd-key={"layer-list-group:"+this.props["data-wd-key"]}
> onClick={_e => this.props.onActiveToggle(!this.props.isActive)}
<button
className="maputnik-layer-list-group-title"
aria-controls={props["aria-controls"]}
aria-expanded={props.isActive}
> >
{props.title} <button
</button> className="maputnik-layer-list-group-title"
<span className="maputnik-space" /> aria-controls={this.props["aria-controls"]}
<Collapser aria-expanded={this.props.isActive}
style={{ height: 14, width: 14 }} >
isCollapsed={props.isActive} {this.props.title}
/> </button>
</div> <span className="maputnik-space" />
</li>; <Collapser
}; style={{ height: 14, width: 14 }}
isCollapsed={this.props.isActive}
/>
</div>
</li>;
}
}
+23 -21
View File
@@ -38,9 +38,9 @@ type IconActionProps = {
classBlockModifier?: string classBlockModifier?: string
}; };
const IconAction: React.FC<IconActionProps> = (props) => { class IconAction extends React.Component<IconActionProps> {
function renderIcon() { renderIcon() {
switch (props.action) { switch (this.props.action) {
case "duplicate": return <MdContentCopy />; case "duplicate": return <MdContentCopy />;
case "show": return <MdVisibility />; case "show": return <MdVisibility />;
case "hide": return <MdVisibilityOff />; case "hide": return <MdVisibilityOff />;
@@ -48,28 +48,30 @@ const IconAction: React.FC<IconActionProps> = (props) => {
} }
} }
const { classBlockName, classBlockModifier } = props; render() {
const { classBlockName, classBlockModifier } = this.props;
let classAdditions = ""; let classAdditions = "";
if (classBlockName) { if (classBlockName) {
classAdditions = `maputnik-layer-list-icon-action__${classBlockName}`; classAdditions = `maputnik-layer-list-icon-action__${classBlockName}`;
if (classBlockModifier) { if (classBlockModifier) {
classAdditions += ` maputnik-layer-list-icon-action__${classBlockName}--${classBlockModifier}`; classAdditions += ` maputnik-layer-list-icon-action__${classBlockName}--${classBlockModifier}`;
}
} }
}
return <button return <button
tabIndex={-1} tabIndex={-1}
title={props.action} title={this.props.action}
className={`maputnik-layer-list-icon-action ${classAdditions}`} className={`maputnik-layer-list-icon-action ${classAdditions}`}
data-wd-key={props.wdKey} data-wd-key={this.props.wdKey}
onClick={props.onClick} onClick={this.props.onClick}
aria-hidden="true" aria-hidden="true"
> >
{renderIcon()} {this.renderIcon()}
</button>; </button>;
}; }
}
type LayerListItemProps = { type LayerListItemProps = {
id?: string id?: string
+205 -235
View File
@@ -1,4 +1,4 @@
import React, {useCallback, useEffect, useReducer, useRef} from "react"; import React from "react";
import {createRoot} from "react-dom/client"; import {createRoot} from "react-dom/client";
import MapLibreGl, {type LayerSpecification, type LngLat, type Map, type MapOptions, type SourceSpecification, type StyleSpecification} from "maplibre-gl"; import MapLibreGl, {type LayerSpecification, type LngLat, type Map, type MapOptions, type SourceSpecification, type StyleSpecification} from "maplibre-gl";
import MaplibreInspect from "@maplibre/maplibre-gl-inspect"; import MaplibreInspect from "@maplibre/maplibre-gl-inspect";
@@ -71,65 +71,199 @@ type MapMaplibreGlInternalProps = {
onChange(value: {center: LngLat, zoom: number, _from: "map" | "app"}): unknown onChange(value: {center: LngLat, zoom: number, _from: "map" | "app"}): unknown
} & WithTranslation; } & WithTranslation;
/** type MapMaplibreGlState = {
* Replacement for the previous `shouldComponentUpdate`. `React.memo` expects the map: Map | null;
* inverse: `true` means "props are equal, skip the render", whereas inspect: MaplibreInspect | null;
* `shouldComponentUpdate` returned `true` when it *should* re-render. geocoder: MaplibreGeocoder | null;
* As before, if the props cannot be serialized we treat them as equal and skip zoomControl: ZoomControl | null;
* the render ("no biggie, carry on"). zoom?: number;
*/ };
function arePropsEqual(prevProps: MapMaplibreGlInternalProps, nextProps: MapMaplibreGlInternalProps) {
let should = false; class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps, MapMaplibreGlState> {
try { static defaultProps = {
should = JSON.stringify(prevProps) !== JSON.stringify(nextProps); onMapLoaded: () => {},
} catch(_e) { onDataChange: () => {},
// no biggie, carry on onLayerSelect: () => {},
onChange: () => {},
options: {} as MapOptions,
};
container: HTMLDivElement | null = null;
constructor(props: MapMaplibreGlInternalProps) {
super(props);
this.state = {
map: null,
inspect: null,
geocoder: null,
zoomControl: null,
};
i18next.on("languageChanged", () => {
this.forceUpdate();
});
} }
return !should;
}
const MapMaplibreGlInternal = ({
onDataChange = () => {},
onLayerSelect = () => {},
onChange = () => {},
options = {},
mapStyle,
mapView,
inspectModeEnabled,
highlightedLayer,
replaceAccessTokens,
t,
}: MapMaplibreGlInternalProps) => {
const container = useRef<HTMLDivElement | null>(null);
// These used to live in `this.state`, but were never able to trigger a shouldComponentUpdate(nextProps: MapMaplibreGlInternalProps, nextState: MapMaplibreGlState) {
// re-render: `shouldComponentUpdate` stringified the state, which throws for let should = false;
// the (circular) maplibre `Map`, so the comparison was swallowed and the try {
// component only ever re-rendered on prop changes. They are imperative should = JSON.stringify(this.props) !== JSON.stringify(nextProps) || JSON.stringify(this.state) !== JSON.stringify(nextState);
// handles, so they are refs here and mutating them does not re-render. } catch(_e) {
const map = useRef<Map | null>(null); // no biggie, carry on
const inspect = useRef<MaplibreInspect | null>(null); }
const geocoder = useRef<MaplibreGeocoder | null>(null); return should;
const zoomControl = useRef<ZoomControl | null>(null); }
const zoom = useRef<number | undefined>(undefined);
// `componentDidUpdate` did not run on mount, so neither may the effect below. componentDidUpdate() {
const hasMounted = useRef(false); const map = this.state.map;
const [, forceUpdate] = useReducer((tick: number) => tick + 1, 0); const styleWithTokens = this.props.replaceAccessTokens(this.props.mapStyle);
if (map) {
// Maplibre GL now does diffing natively so we don't need to calculate
// the necessary operations ourselves!
// We also need to update the style for inspect to work properly
map.setStyle(styleWithTokens, {diff: true});
map.showTileBoundaries = this.props.options?.showTileBoundaries!;
map.showCollisionBoxes = this.props.options?.showCollisionBoxes!;
map.showOverdrawInspector = this.props.options?.showOverdrawInspector!;
// The maplibre event handlers/callbacks below are registered once (on mount) // set the map view when the prop was updated from outside
// but read `this.props` at call time in the class version, so they must not if (this.props.mapView._from === "app") {
// close over the props of the first render. map.jumpTo(this.props.mapView);
const latestProps = useRef({onDataChange, onLayerSelect, onChange, mapStyle, inspectModeEnabled, highlightedLayer, t}); }
latestProps.current = {onDataChange, onLayerSelect, onChange, mapStyle, inspectModeEnabled, highlightedLayer, t}; }
const onLayerSelectById = useCallback((id: string) => { if(this.state.inspect && this.props.inspectModeEnabled !== this.state.inspect._showInspectMap) {
const index = latestProps.current.mapStyle.layers.findIndex(layer => layer.id === id); this.state.inspect.toggleInspector();
latestProps.current.onLayerSelect(index); }
}, []); if (this.state.inspect && this.props.inspectModeEnabled) {
this.state.inspect.setOriginalStyle(styleWithTokens);
// In case the sources are the same, there's a need to refresh the style
setTimeout(() => {
this.state.inspect!.render();
}, 500);
}
const initGeocoder = useCallback((mapInstance: Map) => { }
componentDidMount() {
const mapOpts = {
...this.props.options,
container: this.container!,
style: this.props.mapStyle,
hash: true,
maxZoom: 24,
// make root relative urls in stylefiles work as maplibre gl js does
// not support this for everything:
// https://github.com/maplibre/maplibre-gl-js/issues/6818
transformRequest: (url) => {
if (url.startsWith("/")) {
url = `${window.location.origin}${url}`;
}
return { url };
},
// setting to always load glyphs of CJK fonts from server
// https://maplibre.org/maplibre-gl-js/docs/examples/local-ideographs/
localIdeographFontFamily: false
} satisfies MapOptions;
const protocol = new Protocol({metadata: true});
MapLibreGl.addProtocol("pmtiles",protocol.tile);
const map = new MapLibreGl.Map(mapOpts);
const mapViewChange = () => {
const center = map.getCenter();
const zoom = map.getZoom();
this.props.onChange({center, zoom, _from: "map"});
};
mapViewChange();
map.showTileBoundaries = mapOpts.showTileBoundaries!;
map.showCollisionBoxes = mapOpts.showCollisionBoxes!;
map.showOverdrawInspector = mapOpts.showOverdrawInspector!;
const geocoder = this.initGeocoder(map);
const zoomControl = new ZoomControl();
map.addControl(zoomControl, "top-right");
const nav = new MapLibreGl.NavigationControl({visualizePitch:true});
map.addControl(nav, "top-right");
const tmpNode = document.createElement("div");
const root = createRoot(tmpNode);
const inspectPopup = new MapLibreGl.Popup({
closeOnClick: false
});
const inspect = new MaplibreInspect({
popup: inspectPopup,
showMapPopup: true,
showMapPopupOnHover: false,
showInspectMapPopupOnHover: true,
showInspectButton: false,
blockHoverPopupOnClick: true,
assignLayerColor: (layerId: string, alpha: number) => {
return Color(colors.brightColor(layerId, alpha)).desaturate(0.5).string();
},
buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, this.props.highlightedLayer),
renderPopup: (features: InspectFeature[]) => {
if(this.props.inspectModeEnabled) {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlFeaturePropertyPopup features={features} />);
});
return tmpNode;
} else {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlLayerPopup
features={features}
onLayerSelect={this.onLayerSelectById}
zoom={this.state.zoom}
/>,);
});
return tmpNode;
}
}
});
map.addControl(inspect);
map.on("style.load", () => {
this.setState({
map,
inspect,
geocoder,
zoomControl,
zoom: map.getZoom()
});
});
map.on("data", e => {
if(e.dataType !== "tile") return;
this.props.onDataChange!({
map: this.state.map
});
});
map.on("error", e => {
console.log("ERROR", e);
});
map.on("zoom", _e => {
this.setState({
zoom: map.getZoom()
});
});
map.on("dragend", mapViewChange);
map.on("zoomend", mapViewChange);
}
onLayerSelectById = (id: string) => {
const index = this.props.mapStyle.layers.findIndex(layer => layer.id === id);
this.props.onLayerSelect(index);
};
initGeocoder(map: Map) {
const geocoderConfig = { const geocoderConfig = {
forwardGeocode: async (config: MaplibreGeocoderApiConfig) => { forwardGeocode: async (config: MaplibreGeocoderApiConfig) => {
const features = []; const features = [];
@@ -166,190 +300,26 @@ const MapMaplibreGlInternal = ({
}; };
}, },
} as unknown as MaplibreGeocoderApi; } as unknown as MaplibreGeocoderApi;
const geocoderInstance = new MaplibreGeocoder(geocoderConfig, { const geocoder = new MaplibreGeocoder(geocoderConfig, {
placeholder: latestProps.current.t("Search"), placeholder: this.props.t("Search"),
maplibregl: MapLibreGl, maplibregl: MapLibreGl,
}); });
mapInstance.addControl(geocoderInstance, "top-left"); map.addControl(geocoder, "top-left");
return geocoderInstance; return geocoder;
}, []); }
// Was the `i18next.on("languageChanged", () => this.forceUpdate())` in the render() {
// constructor. `forceUpdate` bypassed `shouldComponentUpdate`; a state update const t = this.props.t;
// likewise bypasses `React.memo`. this.state.geocoder?.setPlaceholder(t("Search"));
useEffect(() => { this.state.zoomControl?.setLabel(t("Zoom:"));
const onLanguageChanged = () => { return <div
forceUpdate(); className="maputnik-map__map"
}; role="region"
i18next.on("languageChanged", onLanguageChanged); aria-label={t("Map view")}
return () => { ref={x => {this.container = x;}}
i18next.off("languageChanged", onLanguageChanged); data-wd-key="maplibre:map"
}; ></div>;
}, []); }
}
// componentDidMount export const MapMaplibreGl = withTranslation()(MapMaplibreGlInternal);
useEffect(() => {
const mapOpts = {
...options,
container: container.current!,
style: mapStyle,
hash: true,
maxZoom: 24,
// make root relative urls in stylefiles work as maplibre gl js does
// not support this for everything:
// https://github.com/maplibre/maplibre-gl-js/issues/6818
transformRequest: (url) => {
if (url.startsWith("/")) {
url = `${window.location.origin}${url}`;
}
return { url };
},
// setting to always load glyphs of CJK fonts from server
// https://maplibre.org/maplibre-gl-js/docs/examples/local-ideographs/
localIdeographFontFamily: false
} satisfies MapOptions;
const protocol = new Protocol({metadata: true});
MapLibreGl.addProtocol("pmtiles",protocol.tile);
const mapInstance = new MapLibreGl.Map(mapOpts);
const mapViewChange = () => {
const center = mapInstance.getCenter();
const currentZoom = mapInstance.getZoom();
latestProps.current.onChange({center, zoom: currentZoom, _from: "map"});
};
mapViewChange();
mapInstance.showTileBoundaries = mapOpts.showTileBoundaries!;
mapInstance.showCollisionBoxes = mapOpts.showCollisionBoxes!;
mapInstance.showOverdrawInspector = mapOpts.showOverdrawInspector!;
const geocoderInstance = initGeocoder(mapInstance);
const zoomControlInstance = new ZoomControl();
mapInstance.addControl(zoomControlInstance, "top-right");
const nav = new MapLibreGl.NavigationControl({visualizePitch:true});
mapInstance.addControl(nav, "top-right");
const tmpNode = document.createElement("div");
const root = createRoot(tmpNode);
const inspectPopup = new MapLibreGl.Popup({
closeOnClick: false
});
const inspectInstance = new MaplibreInspect({
popup: inspectPopup,
showMapPopup: true,
showMapPopupOnHover: false,
showInspectMapPopupOnHover: true,
showInspectButton: false,
blockHoverPopupOnClick: true,
assignLayerColor: (layerId: string, alpha: number) => {
return Color(colors.brightColor(layerId, alpha)).desaturate(0.5).string();
},
buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, latestProps.current.highlightedLayer),
renderPopup: (features: InspectFeature[]) => {
if(latestProps.current.inspectModeEnabled) {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlFeaturePropertyPopup features={features} />);
});
return tmpNode;
} else {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlLayerPopup
features={features}
onLayerSelect={onLayerSelectById}
zoom={zoom.current}
/>,);
});
return tmpNode;
}
}
});
mapInstance.addControl(inspectInstance);
mapInstance.on("style.load", () => {
map.current = mapInstance;
inspect.current = inspectInstance;
geocoder.current = geocoderInstance;
zoomControl.current = zoomControlInstance;
zoom.current = mapInstance.getZoom();
});
mapInstance.on("data", e => {
if(e.dataType !== "tile") return;
latestProps.current.onDataChange!({
map: map.current
});
});
mapInstance.on("error", e => {
console.log("ERROR", e);
});
mapInstance.on("zoom", _e => {
zoom.current = mapInstance.getZoom();
});
mapInstance.on("dragend", mapViewChange);
mapInstance.on("zoomend", mapViewChange);
// Mount only, exactly like componentDidMount: the map is created from the
// props of the first render and kept up to date imperatively below (the
// handlers read `latestProps`, so no prop belongs in the dependencies). The
// class had no componentWillUnmount, so there is no teardown here either.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only: adding mapStyle/options would re-create the map
}, [initGeocoder, onLayerSelectById]);
// componentDidUpdate. It had no `prevProps` guards, so it ran after *every*
// re-render; the equivalent is an effect without a dependency array. Since the
// component only re-renders when `arePropsEqual` reports a prop change (or on
// a language change, as before), this runs exactly as often as it used to.
useEffect(() => {
if (!hasMounted.current) {
// componentDidUpdate does not run on mount.
hasMounted.current = true;
return;
}
const styleWithTokens = replaceAccessTokens(mapStyle);
if (map.current) {
// Maplibre GL now does diffing natively so we don't need to calculate
// the necessary operations ourselves!
// We also need to update the style for inspect to work properly
map.current.setStyle(styleWithTokens, {diff: true});
map.current.showTileBoundaries = options?.showTileBoundaries!;
map.current.showCollisionBoxes = options?.showCollisionBoxes!;
map.current.showOverdrawInspector = options?.showOverdrawInspector!;
// set the map view when the prop was updated from outside
if (mapView._from === "app") {
map.current.jumpTo(mapView);
}
}
if(inspect.current && inspectModeEnabled !== inspect.current._showInspectMap) {
inspect.current.toggleInspector();
}
if (inspect.current && inspectModeEnabled) {
inspect.current.setOriginalStyle(styleWithTokens);
// In case the sources are the same, there's a need to refresh the style
setTimeout(() => {
inspect.current!.render();
}, 500);
}
});
geocoder.current?.setPlaceholder(t("Search"));
zoomControl.current?.setLabel(t("Zoom:"));
return <div
className="maputnik-map__map"
role="region"
aria-label={t("Map view")}
ref={container}
data-wd-key="maplibre:map"
></div>;
};
export const MapMaplibreGl = withTranslation()(React.memo(MapMaplibreGlInternal, arePropsEqual));
@@ -63,13 +63,15 @@ type FeaturePropertyPopupProps = {
features: InspectFeature[] features: InspectFeature[]
}; };
export const FeaturePropertyPopup: React.FC<FeaturePropertyPopupProps> = (props) => { export class FeaturePropertyPopup extends React.Component<FeaturePropertyPopupProps> {
const features = removeDuplicatedFeatures(props.features); render() {
return <div className="maputnik-feature-property-popup" dir="ltr" data-wd-key="feature-property-popup"> const features = removeDuplicatedFeatures(this.props.features);
<table className="maputnik-popup-table"> return <div className="maputnik-feature-property-popup" dir="ltr" data-wd-key="feature-property-popup">
<tbody> <table className="maputnik-popup-table">
{features.map(renderFeature)} <tbody>
</tbody> {features.map(renderFeature)}
</table> </tbody>
</div>; </table>
}; </div>;
}
}
+40 -38
View File
@@ -32,8 +32,8 @@ type FeatureLayerPopupProps = {
zoom?: number zoom?: number
}; };
export const FeatureLayerPopup: React.FC<FeatureLayerPopupProps> = (props) => { export class FeatureLayerPopup extends React.Component<FeatureLayerPopupProps> {
function _getFeatureColor(feature: InspectFeature, _zoom?: number) { _getFeatureColor(feature: InspectFeature, _zoom?: number) {
// Guard because openlayers won't have this // Guard because openlayers won't have this
if (!feature.layer.paint) { if (!feature.layer.paint) {
return; return;
@@ -65,45 +65,47 @@ export const FeatureLayerPopup: React.FC<FeatureLayerPopupProps> = (props) => {
} }
} }
const sources = groupFeaturesBySourceLayer(props.features); render() {
const sources = groupFeaturesBySourceLayer(this.props.features);
const items = Object.keys(sources).map(vectorLayerId => { const items = Object.keys(sources).map(vectorLayerId => {
const layers = sources[vectorLayerId].map((feature: InspectFeature, idx: number) => { const layers = sources[vectorLayerId].map((feature: InspectFeature, idx: number) => {
const featureColor = _getFeatureColor(feature, props.zoom); const featureColor = this._getFeatureColor(feature, this.props.zoom);
return <div return <div
key={idx} key={idx}
className="maputnik-popup-layer" className="maputnik-popup-layer"
>
<div
className="maputnik-popup-layer__swatch"
style={{background: featureColor}}
></div>
<label
className="maputnik-popup-layer__label"
onClick={() => {
props.onLayerSelect(feature.layer.id);
}}
> >
{feature.layer.type && <div
<IconLayer type={feature.layer.type} style={{ className="maputnik-popup-layer__swatch"
width: 14, style={{background: featureColor}}
height: 14, ></div>
paddingRight: 3 <label
}}/> className="maputnik-popup-layer__label"
} onClick={() => {
{feature.layer.id} this.props.onLayerSelect(feature.layer.id);
{feature.counter && <span> × {feature.counter}</span>} }}
</label> >
{feature.layer.type &&
<IconLayer type={feature.layer.type} style={{
width: 14,
height: 14,
paddingRight: 3
}}/>
}
{feature.layer.id}
{feature.counter && <span> × {feature.counter}</span>}
</label>
</div>;
});
return <div key={vectorLayerId}>
<div className="maputnik-popup-layer-id">{vectorLayerId}</div>
{layers}
</div>; </div>;
}); });
return <div key={vectorLayerId}>
<div className="maputnik-popup-layer-id">{vectorLayerId}</div>
{layers}
</div>;
});
return <div className="maputnik-feature-layer-popup" data-wd-key="feature-layer-popup" dir="ltr"> return <div className="maputnik-feature-layer-popup" data-wd-key="feature-layer-popup" dir="ltr">
{items} {items}
</div>; </div>;
}; }
}
+128 -139
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useMemo, useRef, useState} from "react"; import React from "react";
import {throttle} from "lodash"; import {throttle} from "lodash";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
@@ -35,52 +35,56 @@ type MapOpenLayersInternalProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const MapOpenLayersInternal = ({ type MapOpenLayersState = {
onLayerSelect = () => {}, zoom: string
mapStyle, rotation: string
style, cursor: string[]
debugToolbox, center: string[]
replaceAccessTokens, selectedFeatures?: any[]
onChange, };
t,
}: MapOpenLayersInternalProps) => {
const [zoom, setZoom] = useState("0");
const [rotation, setRotation] = useState("0");
const [cursor, setCursor] = useState<string[]>([]);
const [center, setCenter] = useState<string[]>([]);
// Never assigned in the class version either, kept for the popup below.
const [selectedFeatures] = useState<any[] | undefined>(undefined);
// Imperative handles: they were plain instance fields, so they are refs and class MapOpenLayersInternal extends React.Component<MapOpenLayersInternalProps, MapOpenLayersState> {
// mutating them must not re-render. static defaultProps = {
const map = useRef<Map | null>(null); onMapLoaded: () => {},
const container = useRef<HTMLDivElement | null>(null); onDataChange: () => {},
const overlay = useRef<Overlay | undefined>(undefined); onLayerSelect: () => {},
const popupContainer = useRef<HTMLDivElement | null>(null); };
updateStyle: any;
map: any;
container: HTMLDivElement | null = null;
overlay: Overlay | undefined;
popupContainer: HTMLElement | null = null;
// componentDidUpdate did not run on mount, and componentDidMount already constructor(props: MapOpenLayersInternalProps) {
// applies the initial style, so the style effect below must skip the mount. super(props);
const hasMounted = useRef(false); this.state = {
zoom: "0",
rotation: "0",
cursor: [] as string[],
center: [],
};
this.updateStyle = throttle(this._updateStyle.bind(this), 200);
}
// The openlayers event handlers are registered on mount but read `this.props` _updateStyle(newMapStyle: StyleSpecification) {
// at call time in the class version. if(!this.map) return;
const latestProps = useRef({mapStyle, replaceAccessTokens, onChange});
latestProps.current = {mapStyle, replaceAccessTokens, onChange};
// Was `this.updateStyle = throttle(this._updateStyle.bind(this), 200)` in the
// constructor: created once per instance, so it is memoized once here.
const updateStyle = useMemo(() => throttle((newMapStyle: StyleSpecification) => {
if(!map.current) return;
// See <https://github.com/openlayers/ol-mapbox-style/issues/215#issuecomment-493198815> // See <https://github.com/openlayers/ol-mapbox-style/issues/215#issuecomment-493198815>
map.current.getLayers().clear(); this.map.getLayers().clear();
apply(map.current, newMapStyle); apply(this.map, newMapStyle);
}, 200), []); }
// componentDidMount componentDidUpdate(prevProps: MapOpenLayersInternalProps) {
useEffect(() => { if (this.props.mapStyle !== prevProps.mapStyle) {
overlay.current = new Overlay({ this.updateStyle(
element: popupContainer.current!, this.props.replaceAccessTokens(this.props.mapStyle)
);
}
}
componentDidMount() {
this.overlay = new Overlay({
element: this.popupContainer!,
autoPan: { autoPan: {
animation: { animation: {
duration: 250 duration: 250
@@ -88,131 +92,116 @@ const MapOpenLayersInternal = ({
}, },
}); });
const mapInstance = new Map({ const map = new Map({
target: container.current!, target: this.container!,
overlays: [overlay.current], overlays: [this.overlay],
view: new View({ view: new View({
zoom: 1, zoom: 1,
center: [180, -90], center: [180, -90],
}) })
}); });
mapInstance.on("pointermove", (evt) => { map.on("pointermove", (evt) => {
const coords = toLonLat(evt.coordinate); const coords = toLonLat(evt.coordinate);
setCursor([ this.setState({
coords[0].toFixed(2), cursor: [
coords[1].toFixed(2) coords[0].toFixed(2),
]); coords[1].toFixed(2)
]
});
}); });
const onMoveEnd = () => { const onMoveEnd = () => {
const currentZoom = mapInstance.getView().getZoom(); const zoom = map.getView().getZoom();
const currentCenter = toLonLat(mapInstance.getView().getCenter()!); const center = toLonLat(map.getView().getCenter()!);
latestProps.current.onChange({ this.props.onChange({
zoom: currentZoom, zoom,
center: { center: {
lng: currentCenter[0], lng: center[0],
lat: currentCenter[1], lat: center[1],
}, },
}); });
}; };
onMoveEnd(); onMoveEnd();
mapInstance.on("moveend", onMoveEnd); map.on("moveend", onMoveEnd);
mapInstance.on("postrender", (_e) => { map.on("postrender", (_e) => {
const currentCenter = toLonLat(mapInstance.getView().getCenter()!); const center = toLonLat(map.getView().getCenter()!);
setCenter([ this.setState({
currentCenter[0].toFixed(2), center: [
currentCenter[1].toFixed(2), center[0].toFixed(2),
]); center[1].toFixed(2),
setRotation(mapInstance.getView().getRotation().toFixed(2)); ],
setZoom(mapInstance.getView().getZoom()!.toFixed(2)); rotation: map.getView().getRotation().toFixed(2),
zoom: map.getView().getZoom()!.toFixed(2)
});
}); });
map.current = mapInstance; this.map = map;
updateStyle( this.updateStyle(
latestProps.current.replaceAccessTokens(latestProps.current.mapStyle) as StyleSpecification this.props.replaceAccessTokens(this.props.mapStyle)
); );
// Mount only, like componentDidMount. The class had no componentWillUnmount, }
// so there is no teardown here either. The props read above are read from
// latestProps/the first render on purpose, so they are not dependencies.
}, [updateStyle]);
// componentDidUpdate: `if (this.props.mapStyle !== prevProps.mapStyle)`. closeOverlay = (e: any) => {
useEffect(() => {
if (!hasMounted.current) {
// componentDidUpdate does not run on mount; componentDidMount already
// applied the initial style, and applying it twice would push a second
// (throttled) clear + apply through ol-mapbox-style.
hasMounted.current = true;
return;
}
updateStyle(
replaceAccessTokens(mapStyle) as StyleSpecification
);
// `replaceAccessTokens` is deliberately *not* a dependency: the parent
// re-creates it on every render, so depending on it would re-apply the style
// on every render instead of only when `mapStyle` changes, which is exactly
// what the `this.props.mapStyle !== prevProps.mapStyle` guard checked.
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above: replaceAccessTokens is a new identity each render
}, [mapStyle, updateStyle]);
const closeOverlay = useCallback((e: any) => {
e.target.blur(); e.target.blur();
overlay.current!.setPosition(undefined); this.overlay!.setPosition(undefined);
}, []); };
return <div className="maputnik-ol-container"> render() {
<div const t = this.props.t;
ref={popupContainer} return <div className="maputnik-ol-container">
style={{background: "black"}} <div
className="maputnik-popup" ref={x => {this.popupContainer = x;}}
> style={{background: "black"}}
<button className="maputnik-popup"
className="maplibregl-popup-close-button"
onClick={closeOverlay}
aria-label={t("Close popup")}
> >
× <button
</button> className="maplibregl-popup-close-button"
<MapMaplibreGlLayerPopup onClick={this.closeOverlay}
features={selectedFeatures || []} aria-label={t("Close popup")}
onLayerSelect={onLayerSelect} >
/> ×
</div> </button>
<div className="maputnik-ol-zoom"> <MapMaplibreGlLayerPopup
{t("Zoom:")} {zoom} features={this.state.selectedFeatures || []}
</div> onLayerSelect={this.props.onLayerSelect}
{debugToolbox && />
<div className="maputnik-ol-debug">
<div>
<label>{t("cursor:")} </label>
<span>{renderCoords(cursor)}</span>
</div>
<div>
<label>{t("center:")} </label>
<span>{renderCoords(center)}</span>
</div>
<div>
<label>{t("rotation:")} </label>
<span>{rotation}</span>
</div>
</div> </div>
} <div className="maputnik-ol-zoom">
<div {t("Zoom:")} {this.state.zoom}
className="maputnik-ol" </div>
ref={container} {this.props.debugToolbox &&
role="region" <div className="maputnik-ol-debug">
aria-label={t("Map view")} <div>
style={{ <label>{t("cursor:")} </label>
...style, <span>{renderCoords(this.state.cursor)}</span>
}}> </div>
</div> <div>
</div>; <label>{t("center:")} </label>
}; <span>{renderCoords(this.state.center)}</span>
</div>
<div>
<label>{t("rotation:")} </label>
<span>{this.state.rotation}</span>
</div>
</div>
}
<div
className="maputnik-ol"
ref={x => {this.container = x;}}
role="region"
aria-label={t("Map view")}
style={{
...this.props.style,
}}>
</div>
</div>;
}
}
export const MapOpenLayers = withTranslation()(MapOpenLayersInternal); export const MapOpenLayers = withTranslation()(MapOpenLayersInternal);
+29 -27
View File
@@ -40,35 +40,37 @@ type PropertyGroupProps = {
errors?: MappedLayerErrors errors?: MappedLayerErrors
}; };
export const PropertyGroup: React.FC<PropertyGroupProps> = (props) => { export class PropertyGroup extends React.Component<PropertyGroupProps> {
const onPropertyChange = (property: string, newValue: any) => { onPropertyChange = (property: string, newValue: any) => {
const group = getGroupName(props.spec, props.layer.type, property); const group = getGroupName(this.props.spec, this.props.layer.type, property);
props.onChange(group ,property, newValue); this.props.onChange(group ,property, newValue);
}; };
const {errors} = props; render() {
const fields = props.groupFields.map(fieldName => { const {errors} = this.props;
const fieldSpec = getFieldSpec(props.spec, props.layer.type, fieldName); const fields = this.props.groupFields.map(fieldName => {
const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName);
const paint = props.layer.paint || {}; const paint = this.props.layer.paint || {};
const layout = props.layer.layout || {}; const layout = this.props.layer.layout || {};
const fieldValue = fieldName in paint const fieldValue = fieldName in paint
? paint[fieldName as keyof typeof paint] ? paint[fieldName as keyof typeof paint]
: layout[fieldName as keyof typeof layout]; : layout[fieldName as keyof typeof layout];
const fieldType = fieldName in paint ? "paint" : "layout"; const fieldType = fieldName in paint ? "paint" : "layout";
return <FieldFunction return <FieldFunction
errors={errors} errors={errors}
onChange={onPropertyChange} onChange={this.onPropertyChange}
key={fieldName} key={fieldName}
fieldName={fieldName} fieldName={fieldName}
value={fieldValue} value={fieldValue}
fieldType={fieldType} fieldType={fieldType}
fieldSpec={fieldSpec} fieldSpec={fieldSpec}
/>; />;
}); });
return <div className="maputnik-property-group"> return <div className="maputnik-property-group">
{fields} {fields}
</div>; </div>;
}; }
}
+7 -5
View File
@@ -4,8 +4,10 @@ type ScrollContainerProps = {
children?: React.ReactNode children?: React.ReactNode
}; };
export const ScrollContainer: React.FC<ScrollContainerProps> = (props) => { export class ScrollContainer extends React.Component<ScrollContainerProps> {
return <div className="maputnik-scroll-container"> render() {
{props.children} return <div className="maputnik-scroll-container">
</div>; {this.props.children}
}; </div>;
}
}
+41 -38
View File
@@ -40,50 +40,53 @@ type SingleFilterEditorProps = {
properties?: {[key: string]: string} properties?: {[key: string]: string}
}; };
export const SingleFilterEditor: React.FC<SingleFilterEditorProps> = ({ export class SingleFilterEditor extends React.Component<SingleFilterEditorProps> {
properties = {}, static defaultProps = {
...props properties: {},
}) => { };
function onFilterPartChanged(filterOp: string, propertyName: string, filterArgs: string[]) {
onFilterPartChanged(filterOp: string, propertyName: string, filterArgs: string[]) {
let newFilter = [filterOp, propertyName, ...filterArgs.map(parseFilter)]; let newFilter = [filterOp, propertyName, ...filterArgs.map(parseFilter)];
if(filterOp === "has" || filterOp === "!has") { if(filterOp === "has" || filterOp === "!has") {
newFilter = [filterOp, propertyName]; newFilter = [filterOp, propertyName];
} else if(filterArgs.length === 0) { } else if(filterArgs.length === 0) {
newFilter = [filterOp, propertyName, ""]; newFilter = [filterOp, propertyName, ""];
} }
props.onChange(newFilter); this.props.onChange(newFilter);
} }
const f = props.filter; render() {
const filterOp = f[0]; const f = this.props.filter;
const propertyName = f[1]; const filterOp = f[0];
const filterArgs = f.slice(2); const propertyName = f[1];
const filterArgs = f.slice(2);
return <div className="maputnik-filter-editor-single"> return <div className="maputnik-filter-editor-single">
<div className="maputnik-filter-editor-property"> <div className="maputnik-filter-editor-property">
<InputAutocomplete <InputAutocomplete
aria-label="key" aria-label="key"
value={propertyName} value={propertyName}
options={Object.keys(properties).map(propName => [propName, propName])} options={Object.keys(this.props.properties!).map(propName => [propName, propName])}
onChange={(newPropertyName: string) => onFilterPartChanged(filterOp, newPropertyName, filterArgs)} onChange={(newPropertyName: string) => this.onFilterPartChanged(filterOp, newPropertyName, filterArgs)}
/> />
</div> </div>
<div className="maputnik-filter-editor-operator"> <div className="maputnik-filter-editor-operator">
<InputSelect <InputSelect
aria-label="function" aria-label="function"
value={filterOp} value={filterOp}
onChange={(newFilterOp: string) => onFilterPartChanged(newFilterOp, propertyName, filterArgs)} onChange={(newFilterOp: string) => this.onFilterPartChanged(newFilterOp, propertyName, filterArgs)}
options={otherFilterOps} options={otherFilterOps}
/> />
</div> </div>
{filterArgs.length > 0 && {filterArgs.length > 0 &&
<div className="maputnik-filter-editor-args"> <div className="maputnik-filter-editor-args">
<InputString <InputString
aria-label="value" aria-label="value"
value={filterArgs.join(",")} value={filterArgs.join(",")}
onChange={(v: string) => onFilterPartChanged(filterOp, propertyName, v.split(","))} onChange={(v: string) => this.onFilterPartChanged(filterOp, propertyName, v.split(","))}
/> />
</div> </div>
} }
</div>; </div>;
}; }
}
+10 -8
View File
@@ -7,13 +7,15 @@ type SmallErrorInternalProps = {
children?: React.ReactNode children?: React.ReactNode
} & WithTranslation; } & WithTranslation;
const SmallErrorInternal: React.FC<SmallErrorInternalProps> = (props) => { class SmallErrorInternal extends React.Component<SmallErrorInternalProps> {
const t = props.t; render () {
return ( const t = this.props.t;
<div className="SmallError"> return (
{t("Error:")} {props.children} <div className="SmallError">
</div> {t("Error:")} {this.props.children}
); </div>
}; );
}
}
export const SmallError = withTranslation()(SmallErrorInternal); export const SmallError = withTranslation()(SmallErrorInternal);
+23 -19
View File
@@ -19,26 +19,30 @@ type SpecPropertyProps = FieldSpecProps & {
}; };
export const SpecProperty: React.FC<SpecPropertyProps> = (props) => { export class SpecProperty extends React.Component<SpecPropertyProps> {
const {errors = {}, fieldName, fieldType} = props; static defaultProps = {
errors: {},
};
const functionBtn = <FunctionButtons render() {
fieldSpec={props.fieldSpec} const {errors, fieldName, fieldType} = this.props;
onZoomClick={props.onZoomClick}
onDataClick={props.onDataClick}
onExpressionClick={props.onExpressionClick}
onElevationClick={props.onElevationClick}
/>;
const error = errors[fieldType+"."+fieldName as any] as any; const functionBtn = <FunctionButtons
fieldSpec={this.props.fieldSpec}
onZoomClick={this.props.onZoomClick}
onDataClick={this.props.onDataClick}
onExpressionClick={this.props.onExpressionClick}
onElevationClick={this.props.onElevationClick}
/>;
const propsWithDefaults = {...props, errors}; const error = errors![fieldType+"."+fieldName as any] as any;
return <FieldSpec return <FieldSpec
{...propsWithDefaults} {...this.props}
error={error} error={error}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
label={labelFromFieldName(props.fieldName || "")} label={labelFromFieldName(this.props.fieldName || "")}
action={functionBtn} action={functionBtn}
/>; />;
}; }
}
+146 -122
View File
@@ -1,4 +1,4 @@
import React, { useRef } from "react"; import React from "react";
import { PiListPlusBold } from "react-icons/pi"; import { PiListPlusBold } from "react-icons/pi";
import { TbMathFunction } from "react-icons/tb"; import { TbMathFunction } from "react-icons/tb";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
@@ -68,21 +68,41 @@ type ZoomPropertyState = {
refs: {[key: number]: string} refs: {[key: number]: string}
}; };
const ZoomPropertyInternal: React.FC<ZoomPropertyInternalProps> = ({ errors = {}, ...rest }) => { class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, ZoomPropertyState> {
const props = { errors, ...rest } as ZoomPropertyInternalProps; static defaultProps = {
errors: {},
};
// The stop refs never reach the rendered output (the row key is derived from state = {
// the stop itself), so they live in a ref rather than state: keeping them in refs: {} as {[key: number]: string}
// state would mean setting state during render on every pass. };
const refs = useRef<{[key: number]: string}>({});
refs.current = setStopRefs(props, { refs: refs.current }); componentDidMount() {
const newRefs = setStopRefs(this.props, this.state);
if(newRefs) {
this.setState({
refs: newRefs
});
}
}
static getDerivedStateFromProps(props: Readonly<ZoomPropertyInternalProps>, state: ZoomPropertyState) {
const newRefs = setStopRefs(props, state);
if(newRefs) {
return {
refs: newRefs
};
}
return null;
}
// Order the stops altering the refs to reflect their new position. // Order the stops altering the refs to reflect their new position.
function orderStopsByZoom(stops: ZoomWithStops["stops"]) { orderStopsByZoom(stops: ZoomWithStops["stops"]) {
const mappedWithRef = stops const mappedWithRef = stops
.map((stop, idx) => { .map((stop, idx) => {
return { return {
ref: refs.current[idx], ref: this.state.refs[idx],
data: stop data: stop
}; };
}) })
@@ -96,43 +116,145 @@ const ZoomPropertyInternal: React.FC<ZoomPropertyInternalProps> = ({ errors = {}
newRefs[idx] = stop.ref; newRefs[idx] = stop.ref;
}); });
refs.current = newRefs; this.setState({
refs: newRefs
});
return mappedWithRef.map((item) => item.data); return mappedWithRef.map((item) => item.data);
} }
function changeZoomStop(changeIdx: number, stopData: number | undefined, value: number) { changeZoomStop(changeIdx: number, stopData: number | undefined, value: number) {
const stops = (props.value as ZoomWithStops).stops.slice(0); const stops = (this.props.value as ZoomWithStops).stops.slice(0);
stops[changeIdx] = [stopData, value]; stops[changeIdx] = [stopData, value];
const orderedStops = orderStopsByZoom(stops); const orderedStops = this.orderStopsByZoom(stops);
const changedValue = { const changedValue = {
...props.value as ZoomWithStops, ...this.props.value as ZoomWithStops,
stops: orderedStops stops: orderedStops
}; };
props.onChange!(props.fieldName, changedValue); this.props.onChange!(this.props.fieldName, changedValue);
} }
function changeBase(newValue: number | undefined) { changeBase(newValue: number | undefined) {
const changedValue = { const changedValue = {
...props.value, ...this.props.value,
base: newValue base: newValue
}; };
if (changedValue.base === undefined) { if (changedValue.base === undefined) {
delete changedValue["base"]; delete changedValue["base"];
} }
props.onChange!(props.fieldName, changedValue); this.props.onChange!(this.props.fieldName, changedValue);
} }
const changeDataType = (type: string) => { changeDataType = (type: string) => {
if (type !== "interpolate" && props.onChangeToDataFunction) { if (type !== "interpolate" && this.props.onChangeToDataFunction) {
props.onChangeToDataFunction(type); this.props.onChangeToDataFunction(type);
} }
}; };
function getDataFunctionTypes(fieldSpec: { render() {
const t = this.props.t;
const zoomFields = this.props.value?.stops.map((stop, idx) => {
const zoomLevel = stop[0];
const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={this.props.onDeleteStop?.bind(this, idx)} />;
return <tr
key={`${stop[0]}-${stop[1]}`}
>
<td>
<InputNumber
aria-label={t("Zoom")}
value={zoomLevel}
onChange={changedStop => this.changeZoomStop(idx, changedStop, value)}
min={0}
max={22}
/>
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec as any}
value={value}
onChange={(_, newValue) => this.changeZoomStop(idx, zoomLevel, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
// return <div className="maputnik-zoom-spec-property">
return <div className="maputnik-data-spec-block">
<fieldset className="maputnik-data-spec-property">
<legend>{labelFromFieldName(this.props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={"interpolate"}
onChange={(propVal: string) => this.changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={this.getDataFunctionTypes(this.props.fieldSpec!)}
/>
</div>
</Block>
<Block
label={t("Base")}
data-wd-key="function-base"
>
<div className="maputnik-data-spec-property-input">
<InputSpec
fieldName={"base"}
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
value={this.props.value?.base}
onChange={(_, newValue) => this.changeBase(newValue as number | undefined)}
/>
</div>
</Block>
<div className="maputnik-function-stop">
<table className="maputnik-function-stop-table maputnik-function-stop-table--zoom">
<caption>{t("Stops")}</caption>
<thead>
<tr>
<th>{t("Zoom")}</th>
<th rowSpan={2}>{t("Output value")}</th>
</tr>
</thead>
<tbody>
{zoomFields}
</tbody>
</table>
</div>
<div className="maputnik-toolbox">
<InputButton
className="maputnik-add-stop"
onClick={this.props.onAddStop?.bind(this)}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add stop")}
</InputButton>
<InputButton
className="maputnik-add-stop"
data-wd-key="convert-to-expression"
onClick={this.props.onExpressionClick?.bind(this)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
{t("Convert to expression")}
</InputButton>
</div>
</div>
</fieldset>
</div>;
}
getDataFunctionTypes(fieldSpec: {
"property-type"?: string "property-type"?: string
"function-type"?: string "function-type"?: string
}) { }) {
@@ -143,104 +265,6 @@ const ZoomPropertyInternal: React.FC<ZoomPropertyInternalProps> = ({ errors = {}
return ["interpolate"]; return ["interpolate"];
} }
} }
}
const t = props.t;
const zoomFields = props.value?.stops.map((stop, idx) => {
const zoomLevel = stop[0];
const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={props.onDeleteStop?.bind(null, idx)} />;
return <tr
key={`${stop[0]}-${stop[1]}`}
>
<td>
<InputNumber
aria-label={t("Zoom")}
value={zoomLevel}
onChange={changedStop => changeZoomStop(idx, changedStop, value)}
min={0}
max={22}
/>
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec as any}
value={value}
onChange={(_, newValue) => changeZoomStop(idx, zoomLevel, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
// return <div className="maputnik-zoom-spec-property">
return <div className="maputnik-data-spec-block">
<fieldset className="maputnik-data-spec-property">
<legend>{labelFromFieldName(props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={"interpolate"}
onChange={(propVal: string) => changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={getDataFunctionTypes(props.fieldSpec!)}
/>
</div>
</Block>
<Block
label={t("Base")}
data-wd-key="function-base"
>
<div className="maputnik-data-spec-property-input">
<InputSpec
fieldName={"base"}
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
value={props.value?.base}
onChange={(_, newValue) => changeBase(newValue as number | undefined)}
/>
</div>
</Block>
<div className="maputnik-function-stop">
<table className="maputnik-function-stop-table maputnik-function-stop-table--zoom">
<caption>{t("Stops")}</caption>
<thead>
<tr>
<th>{t("Zoom")}</th>
<th rowSpan={2}>{t("Output value")}</th>
</tr>
</thead>
<tbody>
{zoomFields}
</tbody>
</table>
</div>
<div className="maputnik-toolbox">
<InputButton
className="maputnik-add-stop"
onClick={props.onAddStop?.bind(null)}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add stop")}
</InputButton>
<InputButton
className="maputnik-add-stop"
data-wd-key="convert-to-expression"
onClick={props.onExpressionClick?.bind(null)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
{t("Convert to expression")}
</InputButton>
</div>
</div>
</fieldset>
</div>;
};
export const ZoomProperty = withTranslation()(ZoomPropertyInternal); export const ZoomProperty = withTranslation()(ZoomPropertyInternal);
+40 -34
View File
@@ -14,51 +14,57 @@ type ModalInternalProps = PropsWithChildren & {
} & WithTranslation; } & WithTranslation;
const ModalInternal: React.FC<ModalInternalProps> = ({underlayClickExits = true, ...props}) => { class ModalInternal extends React.Component<ModalInternalProps> {
static defaultProps = {
underlayClickExits: true
};
// See <https://github.com/maplibre/maputnik/issues/416> // See <https://github.com/maplibre/maputnik/issues/416>
const onClose = () => { onClose = () => {
if (document.activeElement) { if (document.activeElement) {
(document.activeElement as HTMLElement).blur(); (document.activeElement as HTMLElement).blur();
} }
setTimeout(() => { setTimeout(() => {
props.onOpenToggle(); this.props.onOpenToggle();
}, 0); }, 0);
}; };
const t = props.t; render() {
if(props.isOpen) { const t = this.props.t;
return <AriaModal if(this.props.isOpen) {
titleText={props.title} return <AriaModal
underlayClickExits={underlayClickExits} titleText={this.props.title}
data-wd-key={props["data-wd-key"]} underlayClickExits={this.props.underlayClickExits}
verticallyCenter={true} data-wd-key={this.props["data-wd-key"]}
onExit={onClose} verticallyCenter={true}
dialogClass='maputnik-modal-container' onExit={this.onClose}
> dialogClass='maputnik-modal-container'
<div className={classnames("maputnik-modal", props.className)}
data-wd-key={props["data-wd-key"]}
> >
<header className="maputnik-modal-header"> <div className={classnames("maputnik-modal", this.props.className)}
<h1 className="maputnik-modal-header-title">{props.title}</h1> data-wd-key={this.props["data-wd-key"]}
<span className="maputnik-space"></span> >
<button className="maputnik-modal-header-toggle" <header className="maputnik-modal-header">
title={t("Close modal")} <h1 className="maputnik-modal-header-title">{this.props.title}</h1>
onClick={onClose} <span className="maputnik-space"></span>
data-wd-key={props["data-wd-key"]+".close-modal"} <button className="maputnik-modal-header-toggle"
> title={t("Close modal")}
<MdClose /> onClick={this.onClose}
</button> data-wd-key={this.props["data-wd-key"]+".close-modal"}
</header> >
<div className="maputnik-modal-scroller"> <MdClose />
<div className="maputnik-modal-content">{props.children}</div> </button>
</header>
<div className="maputnik-modal-scroller">
<div className="maputnik-modal-content">{this.props.children}</div>
</div>
</div> </div>
</div> </AriaModal>;
</AriaModal>; }
else {
return false;
}
} }
else { }
return false;
}
};
export const Modal = withTranslation()(ModalInternal); export const Modal = withTranslation()(ModalInternal);
+144 -141
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react"; import React from "react";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import type {LayerSpecification, SourceSpecification} from "maplibre-gl"; import type {LayerSpecification, SourceSpecification} from "maplibre-gl";
@@ -27,166 +27,169 @@ type ModalAddState = {
error?: string | null error?: string | null
}; };
type ModalAddSources = ModalAddInternalProps["sources"]; class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddState> {
addLayer = () => {
function getLayersForSource(allSources: ModalAddSources, source: string) { if (this.props.layers.some(l => l.id === this.state.id)) {
const sourceObj = allSources[source] || {}; this.setState({ error: this.props.t("Layer ID already exists") });
return sourceObj.layers || [];
}
function getSources(allSources: ModalAddSources, type: LayerSpecification["type"]) {
switch(type) {
case "background":
return [];
case "hillshade":
case "color-relief":
return Object.entries(allSources).filter(([_, v]) => v.type === "raster-dem").map(([k, _]) => k);
case "raster":
return Object.entries(allSources).filter(([_, v]) => v.type === "raster").map(([k, _]) => k);
case "heatmap":
case "circle":
case "fill":
case "fill-extrusion":
case "line":
case "symbol":
return Object.entries(allSources).filter(([_, v]) => v.type === "vector" || v.type === "geojson").map(([k, _]) => k);
default:
return [];
}
}
const ModalAddInternal: React.FC<ModalAddInternalProps> = (props) => {
const [type, setType] = useState<LayerSpecification["type"]>("fill");
const [id, setId] = useState<string>("");
const [error, setError] = useState<string | null | undefined>(null);
const [source, setSource] = useState<string | undefined>(() => {
const sourceIds = Object.keys(props.sources);
return sourceIds.length > 0 ? sourceIds[0] : undefined;
});
const [sourceLayer, setSourceLayer] = useState<string | undefined>(() => {
const sourceIds = Object.keys(props.sources);
if (sourceIds.length === 0) return undefined;
const sourceLayers = props.sources[sourceIds[0]].layers || [];
return sourceLayers.length > 0 ? sourceLayers[0] : undefined;
});
// Mirrors `prevState.type` from the previous `componentDidUpdate`.
const prevType = useRef<LayerSpecification["type"]>(type);
useEffect(() => {
// Check if source is valid for new type
const oldType = prevType.current;
prevType.current = type;
// Only when the type has changed (also skips the initial mount)
if (oldType === type) return;
const availableSourcesOld = getSources(props.sources, oldType);
const availableSourcesNew = getSources(props.sources, type);
if(
source !== ""
// Was a valid source previously
&& availableSourcesOld.indexOf(source!) > -1
// And is not a valid source now
&& availableSourcesNew.indexOf(source!) < 0
) {
// Clear the source
setSource("");
}
}, [type, source, props.sources]);
const addLayer = () => {
if (props.layers.some(l => l.id === id)) {
setError(props.t("Layer ID already exists"));
return; return;
} }
const changedLayers = props.layers.slice(0); const changedLayers = this.props.layers.slice(0);
const layer: ModalAddState = { const layer: ModalAddState = {
id: id, id: this.state.id,
type: type, type: this.state.type,
}; };
if(type !== "background") { if(this.state.type !== "background") {
layer.source = source; layer.source = this.state.source;
if(!NON_SOURCE_LAYERS.includes(type) && sourceLayer) { if(!NON_SOURCE_LAYERS.includes(this.state.type) && this.state["source-layer"]) {
layer["source-layer"] = sourceLayer; layer["source-layer"] = this.state["source-layer"];
} }
} }
changedLayers.push(layer as LayerSpecification); changedLayers.push(layer as LayerSpecification);
setError(null); this.setState({ error: null }, () => {
props.onLayersChange(changedLayers); this.props.onLayersChange(changedLayers);
props.onOpenToggle(); this.props.onOpenToggle();
});
}; };
const t = props.t; constructor(props: ModalAddInternalProps) {
const sources = getSources(props.sources, type); super(props);
const layers = getLayersForSource(props.sources, source!); const state: ModalAddState = {
let errorElement; type: "fill",
if (error) { id: "",
errorElement = ( error: null,
<div className="maputnik-modal-error"> };
{error}
<a if(Object.keys(props.sources).length > 0) {
href="#" state.source = Object.keys(this.props.sources)[0];
onClick={() => setError(null)} const sourceLayers = this.props.sources[state.source].layers || [];
className="maputnik-modal-error-close" if (sourceLayers.length > 0) {
> state["source-layer"] = sourceLayers[0];
× }
</a> }
</div> this.state = state;
);
} }
return <Modal componentDidUpdate(_prevProps: ModalAddInternalProps, prevState: ModalAddState) {
isOpen={props.isOpen} // Check if source is valid for new type
onOpenToggle={props.onOpenToggle} const oldType = prevState.type;
title={t("Add Layer")} const newType = this.state.type;
data-wd-key="modal:add-layer"
className="maputnik-add-modal" const availableSourcesOld = this.getSources(oldType);
> const availableSourcesNew = this.getSources(newType);
{errorElement}
<div className="maputnik-add-layer"> if(
<FieldId // Type has changed
value={id} oldType !== newType
wdKey="add-layer.layer-id" && prevState.source !== ""
onChange={(v: string) => { // Was a valid source previously
setId(v); && availableSourcesOld.indexOf(prevState.source!) > -1
setError(null); // And is not a valid source now
}} && availableSourcesNew.indexOf(this.state.source!) < 0
/> ) {
<FieldType // Clear the source
value={type} this.setState({
wdKey="add-layer.layer-type" source: ""
onChange={(v: LayerSpecification["type"]) => setType(v)} });
/> }
{type !== "background" && }
getLayersForSource(source: string) {
const sourceObj = this.props.sources[source] || {};
return sourceObj.layers || [];
}
getSources(type: LayerSpecification["type"]) {
switch(type) {
case "background":
return [];
case "hillshade":
case "color-relief":
return Object.entries(this.props.sources).filter(([_, v]) => v.type === "raster-dem").map(([k, _]) => k);
case "raster":
return Object.entries(this.props.sources).filter(([_, v]) => v.type === "raster").map(([k, _]) => k);
case "heatmap":
case "circle":
case "fill":
case "fill-extrusion":
case "line":
case "symbol":
return Object.entries(this.props.sources).filter(([_, v]) => v.type === "vector" || v.type === "geojson").map(([k, _]) => k);
default:
return [];
}
}
render() {
const t = this.props.t;
const sources = this.getSources(this.state.type);
const layers = this.getLayersForSource(this.state.source!);
let errorElement;
if (this.state.error) {
errorElement = (
<div className="maputnik-modal-error">
{this.state.error}
<a
href="#"
onClick={() => this.setState({ error: null })}
className="maputnik-modal-error-close"
>
×
</a>
</div>
);
}
return <Modal
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={t("Add Layer")}
data-wd-key="modal:add-layer"
className="maputnik-add-modal"
>
{errorElement}
<div className="maputnik-add-layer">
<FieldId
value={this.state.id}
wdKey="add-layer.layer-id"
onChange={(v: string) => {
this.setState({ id: v, error: null });
}}
/>
<FieldType
value={this.state.type}
wdKey="add-layer.layer-type"
onChange={(v: LayerSpecification["type"]) => this.setState({ type: v })}
/>
{this.state.type !== "background" &&
<FieldSource <FieldSource
sourceIds={sources} sourceIds={sources}
wdKey="add-layer.layer-source-block" wdKey="add-layer.layer-source-block"
value={source} value={this.state.source}
onChange={(v: string) => setSource(v)} onChange={(v: string) => this.setState({ source: v })}
/> />
} }
{!NON_SOURCE_LAYERS.includes(type) && {!NON_SOURCE_LAYERS.includes(this.state.type) &&
<FieldSourceLayer <FieldSourceLayer
sourceLayerIds={layers} sourceLayerIds={layers}
value={sourceLayer} value={this.state["source-layer"]}
onChange={(v: string) => setSourceLayer(v)} onChange={(v: string) => this.setState({ "source-layer": v })}
/> />
} }
<InputButton <InputButton
className="maputnik-add-layer-button" className="maputnik-add-layer-button"
onClick={addLayer} onClick={this.addLayer}
data-wd-key="add-layer" data-wd-key="add-layer"
> >
{t("Add Layer")} {t("Add Layer")}
</InputButton> </InputButton>
</div> </div>
</Modal>; </Modal>;
}; }
}
export const ModalAdd = withTranslation()(ModalAddInternal); export const ModalAdd = withTranslation()(ModalAddInternal);
+54 -52
View File
@@ -22,59 +22,61 @@ type ModalDebugInternalProps = {
} & WithTranslation; } & WithTranslation;
const ModalDebugInternal: React.FC<ModalDebugInternalProps> = (props) => { class ModalDebugInternal extends React.Component<ModalDebugInternalProps> {
const {t, mapView} = props; render() {
const {t, mapView} = this.props;
const osmZoom = Math.round(mapView.zoom)+1; const osmZoom = Math.round(mapView.zoom)+1;
const osmLon = +(mapView.center.lng).toFixed(5); const osmLon = +(mapView.center.lng).toFixed(5);
const osmLat = +(mapView.center.lat).toFixed(5); const osmLat = +(mapView.center.lat).toFixed(5);
return <Modal return <Modal
data-wd-key="modal:debug" data-wd-key="modal:debug"
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={props.onOpenToggle} onOpenToggle={this.props.onOpenToggle}
title={t("Debug")} title={t("Debug")}
> >
<section className="maputnik-modal-section maputnik-modal-shortcuts"> <section className="maputnik-modal-section maputnik-modal-shortcuts">
<h1>{t("Options")}</h1> <h1>{t("Options")}</h1>
{props.renderer === "mlgljs" && {this.props.renderer === "mlgljs" &&
<ul> <ul>
{Object.entries(props.maplibreGlDebugOptions!).map(([key, val]) => { {Object.entries(this.props.maplibreGlDebugOptions!).map(([key, val]) => {
return <li key={key}> return <li key={key}>
<label> <label>
<input type="checkbox" checked={val} onChange={(e) => props.onChangeMaplibreGlDebug(key, e.target.checked)} /> {key} <input type="checkbox" checked={val} onChange={(e) => this.props.onChangeMaplibreGlDebug(key, e.target.checked)} /> {key}
</label> </label>
</li>; </li>;
})} })}
</ul> </ul>
} }
{props.renderer === "ol" && {this.props.renderer === "ol" &&
<ul> <ul>
{Object.entries(props.openlayersDebugOptions!).map(([key, val]) => { {Object.entries(this.props.openlayersDebugOptions!).map(([key, val]) => {
return <li key={key}> return <li key={key}>
<label> <label>
<input type="checkbox" checked={val} onChange={(e) => props.onChangeOpenlayersDebug(key, e.target.checked)} /> {key} <input type="checkbox" checked={val} onChange={(e) => this.props.onChangeOpenlayersDebug(key, e.target.checked)} /> {key}
</label> </label>
</li>; </li>;
})} })}
</ul> </ul>
} }
</section> </section>
<section className="maputnik-modal-section"> <section className="maputnik-modal-section">
<h1>{t("Links")}</h1> <h1>{t("Links")}</h1>
<p> <p>
<Trans t={t}> <Trans t={t}>
<a <a
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
href={`https://www.openstreetmap.org/#map=${osmZoom}/${osmLat}/${osmLon}`} href={`https://www.openstreetmap.org/#map=${osmZoom}/${osmLat}/${osmLon}`}
> >
Open in OSM Open in OSM
</a>. Opens the current view on openstreetmap.org </a>. Opens the current view on openstreetmap.org
</Trans> </Trans>
</p> </p>
</section> </section>
</Modal>; </Modal>;
}; }
}
export const ModalDebug = withTranslation()(ModalDebugInternal); export const ModalDebug = withTranslation()(ModalDebugInternal);
+117 -114
View File
@@ -28,31 +28,31 @@ type ModalExportInternalProps = {
} & WithTranslation; } & WithTranslation;
const ModalExportInternal: React.FC<ModalExportInternalProps> = (props) => { class ModalExportInternal extends React.Component<ModalExportInternalProps> {
function tokenizedStyle() { tokenizedStyle() {
return format( return format(
stripAccessTokens( stripAccessTokens(
replaceAccessTokens(props.mapStyle) replaceAccessTokens(this.props.mapStyle)
) )
); );
} }
function exportName() { exportName() {
if (props.mapStyle.name) { if (this.props.mapStyle.name) {
return Slugify(props.mapStyle.name, { return Slugify(this.props.mapStyle.name, {
replacement: "_", replacement: "_",
remove: /[*\-+~.()'"!:]/g, remove: /[*\-+~.()'"!:]/g,
lower: true lower: true
}); });
} else { } else {
return props.mapStyle.id; return this.props.mapStyle.id;
} }
} }
function createHtml() { createHtml() {
const tokenStyle = tokenizedStyle(); const tokenStyle = this.tokenizedStyle();
const htmlTitle = props.mapStyle.name || props.t("Map"); const htmlTitle = this.props.mapStyle.name || this.props.t("Map");
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -80,11 +80,49 @@ const ModalExportInternal: React.FC<ModalExportInternalProps> = (props) => {
`; `;
const blob = new Blob([html], {type: "text/html;charset=utf-8"}); const blob = new Blob([html], {type: "text/html;charset=utf-8"});
const name = exportName(); const exportName = this.exportName();
saveAs(blob, name + ".html"); saveAs(blob, exportName + ".html");
} }
async function createFileHandle(): Promise<FileSystemFileHandle | null> { async saveStyle() {
const tokenStyle = this.tokenizedStyle();
// it is not guaranteed that the File System Access API is available on all
// browsers. If the function is not available, a fallback behavior is used.
if (!showSaveFilePickerAvailable) {
const blob = new Blob([tokenStyle], {type: "application/json;charset=utf-8"});
const exportName = this.exportName();
saveAs(blob, exportName + ".json");
return;
}
let fileHandle = this.props.fileHandle;
if (fileHandle == null) {
fileHandle = await this.createFileHandle();
this.props.onSetFileHandle(fileHandle);
if (fileHandle == null) return;
}
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
this.props.onOpenToggle();
}
async saveStyleAs() {
const tokenStyle = this.tokenizedStyle();
const fileHandle = await this.createFileHandle();
this.props.onSetFileHandle(fileHandle);
if (fileHandle == null) return;
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
this.props.onOpenToggle();
}
async createFileHandle(): Promise<FileSystemFileHandle | null> {
const pickerOpts: SaveFilePickerOptions = { const pickerOpts: SaveFilePickerOptions = {
types: [ types: [
{ {
@@ -92,126 +130,91 @@ const ModalExportInternal: React.FC<ModalExportInternalProps> = (props) => {
accept: {"application/json": [".json"]}, accept: {"application/json": [".json"]},
}, },
], ],
suggestedName: exportName(), suggestedName: this.exportName(),
}; };
const fileHandle = await window.showSaveFilePicker(pickerOpts) as FileSystemFileHandle; const fileHandle = await window.showSaveFilePicker(pickerOpts) as FileSystemFileHandle;
props.onSetFileHandle(fileHandle); this.props.onSetFileHandle(fileHandle);
return fileHandle; return fileHandle;
} }
async function saveStyle() { changeMetadataProperty(property: string, value: any) {
const tokenStyle = tokenizedStyle();
// it is not guaranteed that the File System Access API is available on all
// browsers. If the function is not available, a fallback behavior is used.
if (!showSaveFilePickerAvailable) {
const blob = new Blob([tokenStyle], {type: "application/json;charset=utf-8"});
const name = exportName();
saveAs(blob, name + ".json");
return;
}
let fileHandle = props.fileHandle;
if (fileHandle == null) {
fileHandle = await createFileHandle();
props.onSetFileHandle(fileHandle);
if (fileHandle == null) return;
}
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
props.onOpenToggle();
}
async function saveStyleAs() {
const tokenStyle = tokenizedStyle();
const fileHandle = await createFileHandle();
props.onSetFileHandle(fileHandle);
if (fileHandle == null) return;
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
props.onOpenToggle();
}
function changeMetadataProperty(property: string, value: any) {
const changedStyle = { const changedStyle = {
...props.mapStyle, ...this.props.mapStyle,
metadata: { metadata: {
...props.mapStyle.metadata as any, ...this.props.mapStyle.metadata as any,
[property]: value [property]: value
} }
}; };
props.onStyleChanged(changedStyle); this.props.onStyleChanged(changedStyle);
} }
const t = props.t;
const fsa = fieldSpecAdditional(t);
return <Modal
data-wd-key="modal:export"
isOpen={props.isOpen}
onOpenToggle={props.onOpenToggle}
title={t("Save Style")}
className="maputnik-export-modal"
>
<section className="maputnik-modal-section"> render() {
<h1>{t("Save Style")}</h1> const t = this.props.t;
<p> const fsa = fieldSpecAdditional(t);
{t("Save the JSON style to your computer.")} return <Modal
</p> data-wd-key="modal:export"
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={t("Save Style")}
className="maputnik-export-modal"
>
<div> <section className="maputnik-modal-section">
<FieldString <h1>{t("Save Style")}</h1>
label={fsa.maputnik.maptiler_access_token.label} <p>
fieldSpec={fsa.maputnik.maptiler_access_token} {t("Save the JSON style to your computer.")}
value={(props.mapStyle.metadata || {} as any)["maputnik:openmaptiles_access_token"]} </p>
onChange={changeMetadataProperty.bind(null, "maputnik:openmaptiles_access_token")}
/>
<FieldString
label={fsa.maputnik.thunderforest_access_token.label}
fieldSpec={fsa.maputnik.thunderforest_access_token}
value={(props.mapStyle.metadata || {} as any)["maputnik:thunderforest_access_token"]}
onChange={changeMetadataProperty.bind(null, "maputnik:thunderforest_access_token")}
/>
<FieldString
label={fsa.maputnik.stadia_access_token.label}
fieldSpec={fsa.maputnik.stadia_access_token}
value={(props.mapStyle.metadata || {} as any)["maputnik:stadia_access_token"]}
onChange={changeMetadataProperty.bind(null, "maputnik:stadia_access_token")}
/>
<FieldString
label={fsa.maputnik.locationiq_access_token.label}
fieldSpec={fsa.maputnik.locationiq_access_token}
value={(props.mapStyle.metadata || {} as any)["maputnik:locationiq_access_token"]}
onChange={changeMetadataProperty.bind(null, "maputnik:locationiq_access_token")}
/>
</div>
<div className="maputnik-modal-export-buttons"> <div>
<InputButton onClick={saveStyle}> <FieldString
<MdSave/> label={fsa.maputnik.maptiler_access_token.label}
{t("Save")} fieldSpec={fsa.maputnik.maptiler_access_token}
</InputButton> value={(this.props.mapStyle.metadata || {} as any)["maputnik:openmaptiles_access_token"]}
{showSaveFilePickerAvailable && ( onChange={this.changeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
<InputButton onClick={saveStyleAs}> />
<FieldString
label={fsa.maputnik.thunderforest_access_token.label}
fieldSpec={fsa.maputnik.thunderforest_access_token}
value={(this.props.mapStyle.metadata || {} as any)["maputnik:thunderforest_access_token"]}
onChange={this.changeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")}
/>
<FieldString
label={fsa.maputnik.stadia_access_token.label}
fieldSpec={fsa.maputnik.stadia_access_token}
value={(this.props.mapStyle.metadata || {} as any)["maputnik:stadia_access_token"]}
onChange={this.changeMetadataProperty.bind(this, "maputnik:stadia_access_token")}
/>
<FieldString
label={fsa.maputnik.locationiq_access_token.label}
fieldSpec={fsa.maputnik.locationiq_access_token}
value={(this.props.mapStyle.metadata || {} as any)["maputnik:locationiq_access_token"]}
onChange={this.changeMetadataProperty.bind(this, "maputnik:locationiq_access_token")}
/>
</div>
<div className="maputnik-modal-export-buttons">
<InputButton onClick={this.saveStyle.bind(this)}>
<MdSave/> <MdSave/>
{t("Save as")} {t("Save")}
</InputButton> </InputButton>
)} {showSaveFilePickerAvailable && (
<InputButton onClick={this.saveStyleAs.bind(this)}>
<MdSave/>
{t("Save as")}
</InputButton>
)}
<InputButton onClick={createHtml}> <InputButton onClick={this.createHtml.bind(this)}>
<MdMap/> <MdMap/>
{t("Create HTML")} {t("Create HTML")}
</InputButton> </InputButton>
</div> </div>
</section> </section>
</Modal>; </Modal>;
}; }
}
export const ModalExport = withTranslation()(ModalExportInternal); export const ModalExport = withTranslation()(ModalExportInternal);
+21 -19
View File
@@ -13,24 +13,26 @@ type ModalLoadingInternalProps = {
} & WithTranslation; } & WithTranslation;
const ModalLoadingInternal: React.FC<ModalLoadingInternalProps> = (props) => { class ModalLoadingInternal extends React.Component<ModalLoadingInternalProps> {
const t = props.t; render() {
return <Modal const t = this.props.t;
data-wd-key="modal:loading" return <Modal
isOpen={props.isOpen} data-wd-key="modal:loading"
underlayClickExits={false} isOpen={this.props.isOpen}
title={props.title} underlayClickExits={false}
onOpenToggle={() => props.onCancel()} title={this.props.title}
> onOpenToggle={() => this.props.onCancel()}
<p> >
{props.message} <p>
</p> {this.props.message}
<p className="maputnik-dialog__buttons"> </p>
<InputButton onClick={(e) => props.onCancel(e)}> <p className="maputnik-dialog__buttons">
{t("Cancel")} <InputButton onClick={(e) => this.props.onCancel(e)}>
</InputButton> {t("Cancel")}
</p> </InputButton>
</Modal>; </p>
}; </Modal>;
}
}
export const ModalLoading = withTranslation()(ModalLoadingInternal); export const ModalLoading = withTranslation()(ModalLoadingInternal);
+225 -193
View File
@@ -1,4 +1,4 @@
import React, { type DragEvent, type FormEvent, useRef, useState } from "react"; import React, { type DragEvent, type FormEvent } from "react";
import { MdFileUpload } from "react-icons/md"; import { MdFileUpload } from "react-icons/md";
import { MdAddCircleOutline } from "react-icons/md"; import { MdAddCircleOutline } from "react-icons/md";
import { Trans, type WithTranslation, withTranslation } from "react-i18next"; import { Trans, type WithTranslation, withTranslation } from "react-i18next";
@@ -18,27 +18,29 @@ type PublicStyleProps = {
onSelect(...args: unknown[]): unknown onSelect(...args: unknown[]): unknown
}; };
const PublicStyle: React.FC<PublicStyleProps> = (props) => { class PublicStyle extends React.Component<PublicStyleProps> {
return <div className="maputnik-public-style"> render() {
<InputButton return <div className="maputnik-public-style">
className="maputnik-public-style-button" <InputButton
aria-label={props.title} className="maputnik-public-style-button"
onClick={() => props.onSelect(props.url)} aria-label={this.props.title}
> onClick={() => this.props.onSelect(this.props.url)}
<div className="maputnik-public-style-header"> >
<div>{props.title}</div> <div className="maputnik-public-style-header">
<span className="maputnik-space" /> <div>{this.props.title}</div>
<MdAddCircleOutline /> <span className="maputnik-space" />
</div> <MdAddCircleOutline />
<div </div>
className="maputnik-public-style-thumbnail" <div
style={{ className="maputnik-public-style-thumbnail"
backgroundImage: `url(${props.thumbnailUrl})` style={{
}} backgroundImage: `url(${this.props.thumbnailUrl})`
></div> }}
</InputButton> ></div>
</div>; </InputButton>
}; </div>;
}
}
type ModalOpenInternalProps = { type ModalOpenInternalProps = {
isOpen: boolean isOpen: boolean
@@ -47,32 +49,46 @@ type ModalOpenInternalProps = {
fileHandle: FileSystemFileHandle | null fileHandle: FileSystemFileHandle | null
} & WithTranslation; } & WithTranslation;
const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => { type ModalOpenState = {
const fileInputRef = useRef<HTMLInputElement>(null); styleUrl: string
isDragOver: boolean
error?: string | null
activeRequest?: any
activeRequestUrl?: string | null
};
const [styleUrl, setStyleUrl] = useState<string>(""); class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpenState> {
const [isDragOver, setIsDragOver] = useState<boolean>(false); private fileInputRef = React.createRef<HTMLInputElement>();
const [error, setError] = useState<string | null | undefined>(undefined);
const [activeRequest, setActiveRequest] = useState<any>(undefined);
const [activeRequestUrl, setActiveRequestUrl] = useState<string | null | undefined>(undefined);
const clearError = () => { constructor(props: ModalOpenInternalProps) {
setError(null); super(props);
}; this.state = {
styleUrl: "",
isDragOver: false,
};
}
const onCancelActiveRequest = (e: Event) => { clearError() {
this.setState({
error: null
});
}
onCancelActiveRequest(e: Event) {
// Else the click propagates to the underlying modal // Else the click propagates to the underlying modal
if (e) e.stopPropagation(); if (e) e.stopPropagation();
if (activeRequest) { if (this.state.activeRequest) {
activeRequest.abort(); this.state.activeRequest.abort();
setActiveRequest(null); this.setState({
setActiveRequestUrl(null); activeRequest: null,
activeRequestUrl: null
});
} }
}; }
const onStyleSelect = (styleUrl: string) => { onStyleSelect = (styleUrl: string) => {
clearError(); this.clearError();
let canceled: boolean = false; let canceled: boolean = false;
@@ -88,37 +104,43 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
return; return;
} }
setActiveRequest(null); this.setState({
setActiveRequestUrl(null); activeRequest: null,
activeRequestUrl: null
});
const mapStyle = ensureStyleValidity(body); const mapStyle = ensureStyleValidity(body);
console.log("Loaded style ", mapStyle.id); console.log("Loaded style ", mapStyle.id);
props.onStyleOpen(mapStyle); this.props.onStyleOpen(mapStyle);
onOpenToggle(); this.onOpenToggle();
}) })
.catch((err) => { .catch((err) => {
setError(`Failed to load: '${styleUrl}'`); this.setState({
setActiveRequest(null); error: `Failed to load: '${styleUrl}'`,
setActiveRequestUrl(null); activeRequest: null,
activeRequestUrl: null
});
console.error(err); console.error(err);
console.warn("Could not open the style URL", styleUrl); console.warn("Could not open the style URL", styleUrl);
}); });
setActiveRequest({ this.setState({
abort: function () { activeRequest: {
canceled = true; abort: function () {
} canceled = true;
}
},
activeRequestUrl: styleUrl
}); });
setActiveRequestUrl(styleUrl);
}; };
const onSubmitUrl = (e: FormEvent<HTMLFormElement>) => { onSubmitUrl = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
onStyleSelect(styleUrl); this.onStyleSelect(this.state.styleUrl);
}; };
const onOpenFile = async () => { onOpenFile = async () => {
clearError(); this.clearError();
const pickerOpts: OpenFilePickerOptions = { const pickerOpts: OpenFilePickerOptions = {
types: [ types: [
@@ -138,24 +160,26 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
try { try {
mapStyle = JSON.parse(content); mapStyle = JSON.parse(content);
} catch (err) { } catch (err) {
setError((err as Error).toString()); this.setState({
error: (err as Error).toString()
});
return; return;
} }
mapStyle = ensureStyleValidity(mapStyle); mapStyle = ensureStyleValidity(mapStyle);
props.onStyleOpen(mapStyle, fileHandle); this.props.onStyleOpen(mapStyle, fileHandle);
onOpenToggle(); this.onOpenToggle();
return file; return file;
}; };
// it is not guaranteed that the File System Access API is available on all // it is not guaranteed that the File System Access API is available on all
// browsers. If the function is not available, a fallback behavior is used. // browsers. If the function is not available, a fallback behavior is used.
const onFileChanged = (files: FileList | null) => { onFileChanged = (files: FileList | null) => {
if (!files) return; if (!files) return;
if (files.length === 0) return; if (files.length === 0) return;
const file = files[0]; const file = files[0];
const reader = new FileReader(); const reader = new FileReader();
clearError(); this.clearError();
reader.readAsText(file, "UTF-8"); reader.readAsText(file, "UTF-8");
reader.onload = e => { reader.onload = e => {
@@ -164,172 +188,180 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
mapStyle = JSON.parse(e.target?.result as string); mapStyle = JSON.parse(e.target?.result as string);
} }
catch (err) { catch (err) {
setError((err as Error).toString()); this.setState({
error: (err as Error).toString()
});
return; return;
} }
mapStyle = ensureStyleValidity(mapStyle); mapStyle = ensureStyleValidity(mapStyle);
props.onStyleOpen(mapStyle); this.props.onStyleOpen(mapStyle);
onOpenToggle(); this.onOpenToggle();
}; };
reader.onerror = e => console.log(e.target); reader.onerror = e => console.log(e.target);
}; };
const onOpenToggle = () => { onOpenToggle() {
setStyleUrl(""); this.setState({
setIsDragOver(false); styleUrl: "",
clearError(); isDragOver: false,
props.onOpenToggle(); });
}; this.clearError();
this.props.onOpenToggle();
}
const onBrowseClick = async () => { onBrowseClick = async () => {
if (typeof window.showOpenFilePicker === "function") { if (typeof window.showOpenFilePicker === "function") {
await onOpenFile(); await this.onOpenFile();
return; return;
} }
fileInputRef.current?.click(); this.fileInputRef.current?.click();
}; };
const onFileDragOver = (e: DragEvent<HTMLDivElement>) => { onFileDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!isDragOver) { if (!this.state.isDragOver) {
setIsDragOver(true); this.setState({ isDragOver: true });
} }
}; };
const onFileDragLeave = (e: DragEvent<HTMLDivElement>) => { onFileDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragOver(false); this.setState({ isDragOver: false });
}; };
const onFileDrop = (e: DragEvent<HTMLDivElement>) => { onFileDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragOver(false); this.setState({ isDragOver: false });
onFileChanged(e.dataTransfer.files); this.onFileChanged(e.dataTransfer.files);
}; };
const onChangeUrl = (url: string) => { onChangeUrl = (url: string) => {
setStyleUrl(url); this.setState({
styleUrl: url,
});
}; };
const t = props.t; render() {
const styleOptions = publicStyles.map(style => { const t = this.props.t;
return <PublicStyle const styleOptions = publicStyles.map(style => {
key={style.id} return <PublicStyle
url={style.url} key={style.id}
title={style.title} url={style.url}
thumbnailUrl={style.thumbnail} title={style.title}
onSelect={onStyleSelect} thumbnailUrl={style.thumbnail}
/>; onSelect={this.onStyleSelect}
}); />;
});
let errorElement; let errorElement;
if (error) { if (this.state.error) {
errorElement = ( errorElement = (
<div className="maputnik-modal-error"> <div className="maputnik-modal-error">
{error} {this.state.error}
<a href="#" onClick={() => clearError()} className="maputnik-modal-error-close">×</a> <a href="#" onClick={() => this.clearError()} className="maputnik-modal-error-close">×</a>
</div>
);
}
return (
<div>
<Modal
data-wd-key="modal:open"
isOpen={this.props.isOpen}
onOpenToggle={() => this.onOpenToggle()}
title={t("Open Style")}
>
{errorElement}
<section className="maputnik-modal-section">
<h1>{t("Open local Style")}</h1>
<p>{t("Open a local JSON style from your computer.")}</p>
<div
data-wd-key="modal:open.dropzone"
className={`maputnik-upload-dropzone${this.state.isDragOver ? " maputnik-upload-dropzone--active" : ""}`}
role="button"
tabIndex={0}
onDragOver={this.onFileDragOver}
onDragLeave={this.onFileDragLeave}
onDrop={this.onFileDrop}
onClick={() => void this.onBrowseClick()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void this.onBrowseClick();
}
}}
>
<div className="maputnik-upload-dropzone-content">
<MdFileUpload className="maputnik-upload-dropzone-icon" aria-hidden="true" />
<p className="maputnik-upload-dropzone-text">
{t("Drag and drop a style JSON file here or click to browse")}
</p>
</div>
<input
ref={this.fileInputRef}
data-wd-key="modal:open.file.input"
type="file"
style={{ display: "none" }}
onChange={(e) => this.onFileChanged(e.target.files)}
/>
</div>
</section>
<section className="maputnik-modal-section">
<form onSubmit={this.onSubmitUrl}>
<h1>{t("Load from URL")}</h1>
<p>
<Trans t={t}>
Load from a URL. Note that the URL must have <a href="https://enable-cors.org" target="_blank" rel="noopener noreferrer">CORS enabled</a>.
</Trans>
</p>
<InputUrl
aria-label={t("Style URL")}
data-wd-key="modal:open.url.input"
type="text"
className="maputnik-input"
default={t("Enter URL...")}
value={this.state.styleUrl}
onInput={this.onChangeUrl}
onChange={this.onChangeUrl}
/>
<div>
<InputButton
data-wd-key="modal:open.url.button"
type="submit"
className="maputnik-big-button"
disabled={this.state.styleUrl.length < 1}
>Load from URL</InputButton>
</div>
</form>
</section>
<section className="maputnik-modal-section maputnik-modal-section--shrink">
<h1>{t("Gallery Styles")}</h1>
<p>
{t("Open one of the publicly available styles to start from.")}
</p>
<div className="maputnik-style-gallery-container">
{styleOptions}
</div>
</section>
</Modal>
<ModalLoading
isOpen={!!this.state.activeRequest}
title={t("Loading style")}
onCancel={(e: Event) => this.onCancelActiveRequest(e)}
message={t("Loading") + ": " + this.state.activeRequestUrl}
/>
</div> </div>
); );
} }
}
return (
<div>
<Modal
data-wd-key="modal:open"
isOpen={props.isOpen}
onOpenToggle={() => onOpenToggle()}
title={t("Open Style")}
>
{errorElement}
<section className="maputnik-modal-section">
<h1>{t("Open local Style")}</h1>
<p>{t("Open a local JSON style from your computer.")}</p>
<div
data-wd-key="modal:open.dropzone"
className={`maputnik-upload-dropzone${isDragOver ? " maputnik-upload-dropzone--active" : ""}`}
role="button"
tabIndex={0}
onDragOver={onFileDragOver}
onDragLeave={onFileDragLeave}
onDrop={onFileDrop}
onClick={() => void onBrowseClick()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void onBrowseClick();
}
}}
>
<div className="maputnik-upload-dropzone-content">
<MdFileUpload className="maputnik-upload-dropzone-icon" aria-hidden="true" />
<p className="maputnik-upload-dropzone-text">
{t("Drag and drop a style JSON file here or click to browse")}
</p>
</div>
<input
ref={fileInputRef}
data-wd-key="modal:open.file.input"
type="file"
style={{ display: "none" }}
onChange={(e) => onFileChanged(e.target.files)}
/>
</div>
</section>
<section className="maputnik-modal-section">
<form onSubmit={onSubmitUrl}>
<h1>{t("Load from URL")}</h1>
<p>
<Trans t={t}>
Load from a URL. Note that the URL must have <a href="https://enable-cors.org" target="_blank" rel="noopener noreferrer">CORS enabled</a>.
</Trans>
</p>
<InputUrl
aria-label={t("Style URL")}
data-wd-key="modal:open.url.input"
type="text"
className="maputnik-input"
default={t("Enter URL...")}
value={styleUrl}
onInput={onChangeUrl}
onChange={onChangeUrl}
/>
<div>
<InputButton
data-wd-key="modal:open.url.button"
type="submit"
className="maputnik-big-button"
disabled={styleUrl.length < 1}
>Load from URL</InputButton>
</div>
</form>
</section>
<section className="maputnik-modal-section maputnik-modal-section--shrink">
<h1>{t("Gallery Styles")}</h1>
<p>
{t("Open one of the publicly available styles to start from.")}
</p>
<div className="maputnik-style-gallery-container">
{styleOptions}
</div>
</section>
</Modal>
<ModalLoading
isOpen={!!activeRequest}
title={t("Loading style")}
onCancel={(e: Event) => onCancelActiveRequest(e)}
message={t("Loading") + ": " + activeRequestUrl}
/>
</div>
);
};
export const ModalOpen = withTranslation()(ModalOpenInternal); export const ModalOpen = withTranslation()(ModalOpenInternal);
+214 -212
View File
@@ -24,10 +24,10 @@ type ModalSettingsInternalProps = {
onOpenToggle(): void onOpenToggle(): void
} & WithTranslation; } & WithTranslation;
const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => { class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps> {
function changeTransitionProperty(property: keyof TransitionSpecification, value: number | undefined) { changeTransitionProperty(property: keyof TransitionSpecification, value: number | undefined) {
const transition = { const transition = {
...props.mapStyle.transition, ...this.props.mapStyle.transition,
}; };
if (value === undefined) { if (value === undefined) {
@@ -37,15 +37,15 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
transition[property] = value; transition[property] = value;
} }
props.onStyleChanged({ this.props.onStyleChanged({
...props.mapStyle, ...this.props.mapStyle,
transition, transition,
}); });
} }
function changeLightProperty(property: keyof LightSpecification, value: any) { changeLightProperty(property: keyof LightSpecification, value: any) {
const light = { const light = {
...props.mapStyle.light, ...this.props.mapStyle.light,
}; };
if (value === undefined) { if (value === undefined) {
@@ -56,15 +56,15 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
light[property] = value; light[property] = value;
} }
props.onStyleChanged({ this.props.onStyleChanged({
...props.mapStyle, ...this.props.mapStyle,
light, light,
}); });
} }
function changeTerrainProperty(property: keyof TerrainSpecification, value: any) { changeTerrainProperty(property: keyof TerrainSpecification, value: any) {
const terrain = { const terrain = {
...props.mapStyle.terrain, ...this.props.mapStyle.terrain,
} as TerrainSpecification; } as TerrainSpecification;
if (value === undefined) { if (value === undefined) {
@@ -75,15 +75,15 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
terrain[property] = value; terrain[property] = value;
} }
props.onStyleChanged({ this.props.onStyleChanged({
...props.mapStyle, ...this.props.mapStyle,
terrain, terrain,
}); });
} }
function changeProjectionType(value: any) { changeProjectionType(value: any) {
const projection = { const projection = {
...props.mapStyle.projection, ...this.props.mapStyle.projection,
} as ProjectionSpecification; } as ProjectionSpecification;
if (value === undefined) { if (value === undefined) {
@@ -93,15 +93,15 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
projection.type = value; projection.type = value;
} }
props.onStyleChanged({ this.props.onStyleChanged({
...props.mapStyle, ...this.props.mapStyle,
projection, projection,
}); });
} }
function changeStyleProperty(property: keyof StyleSpecification | "owner", value: any) { changeStyleProperty(property: keyof StyleSpecification | "owner", value: any) {
const changedStyle = { const changedStyle = {
...props.mapStyle, ...this.props.mapStyle,
}; };
if (value === undefined) { if (value === undefined) {
@@ -112,222 +112,224 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
// @ts-ignore // @ts-ignore
changedStyle[property] = value; changedStyle[property] = value;
} }
props.onStyleChanged(changedStyle); this.props.onStyleChanged(changedStyle);
} }
const metadata = props.mapStyle.metadata || {} as any; render() {
const {t, onChangeMetadataProperty, mapStyle} = props; const metadata = this.props.mapStyle.metadata || {} as any;
const fsa = fieldSpecAdditional(t); const {t, onChangeMetadataProperty, mapStyle} = this.props;
const fsa = fieldSpecAdditional(t);
const light = props.mapStyle.light || {}; const light = this.props.mapStyle.light || {};
const transition = props.mapStyle.transition || {}; const transition = this.props.mapStyle.transition || {};
const terrain = props.mapStyle.terrain || {} as TerrainSpecification; const terrain = this.props.mapStyle.terrain || {} as TerrainSpecification;
const projection = props.mapStyle.projection || {} as ProjectionSpecification; const projection = this.props.mapStyle.projection || {} as ProjectionSpecification;
return <Modal return <Modal
data-wd-key="modal:settings" data-wd-key="modal:settings"
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={props.onOpenToggle} onOpenToggle={this.props.onOpenToggle}
title={t("Style Settings")} title={t("Style Settings")}
> >
<div className="modal:settings"> <div className="modal:settings">
<FieldString <FieldString
label={t("Name")} label={t("Name")}
fieldSpec={latest.$root.name} fieldSpec={latest.$root.name}
data-wd-key="modal:settings.name" data-wd-key="modal:settings.name"
value={props.mapStyle.name} value={this.props.mapStyle.name}
onChange={(value) => changeStyleProperty("name", value)} onChange={(value) => this.changeStyleProperty("name", value)}
/>
<FieldString
label={t("Owner")}
fieldSpec={{doc: t("Owner ID of the style. Used by Mapbox or future style APIs.")}}
data-wd-key="modal:settings.owner"
value={(props.mapStyle as any).owner}
onChange={(value) => changeStyleProperty("owner", value)}
/>
<Block label={t("Sprite URL")} fieldSpec={latest.$root.sprite} data-wd-key="modal:settings.sprite">
<FieldJson
lintType="json"
value={props.mapStyle.sprite as any}
onChange={(value) => changeStyleProperty("sprite", value)}
/> />
</Block> <FieldString
label={t("Owner")}
fieldSpec={{doc: t("Owner ID of the style. Used by Mapbox or future style APIs.")}}
data-wd-key="modal:settings.owner"
value={(this.props.mapStyle as any).owner}
onChange={(value) => this.changeStyleProperty("owner", value)}
/>
<Block label={t("Sprite URL")} fieldSpec={latest.$root.sprite} data-wd-key="modal:settings.sprite">
<FieldJson
lintType="json"
value={this.props.mapStyle.sprite as any}
onChange={(value) => this.changeStyleProperty("sprite", value)}
/>
</Block>
<FieldUrl <FieldUrl
label={t("Glyphs URL")} label={t("Glyphs URL")}
fieldSpec={latest.$root.glyphs} fieldSpec={latest.$root.glyphs}
data-wd-key="modal:settings.glyphs" data-wd-key="modal:settings.glyphs"
value={props.mapStyle.glyphs as string} value={this.props.mapStyle.glyphs as string}
onChange={(value) => changeStyleProperty("glyphs", value)} onChange={(value) => this.changeStyleProperty("glyphs", value)}
/> />
<FieldString <FieldString
label={fsa.maputnik.maptiler_access_token.label} label={fsa.maputnik.maptiler_access_token.label}
fieldSpec={fsa.maputnik.maptiler_access_token} fieldSpec={fsa.maputnik.maptiler_access_token}
data-wd-key="modal:settings.maputnik:openmaptiles_access_token" data-wd-key="modal:settings.maputnik:openmaptiles_access_token"
value={metadata["maputnik:openmaptiles_access_token"]} value={metadata["maputnik:openmaptiles_access_token"]}
onChange={(value) => onChangeMetadataProperty("maputnik:openmaptiles_access_token", value)} onChange={(value) => onChangeMetadataProperty("maputnik:openmaptiles_access_token", value)}
/> />
<FieldString <FieldString
label={fsa.maputnik.thunderforest_access_token.label} label={fsa.maputnik.thunderforest_access_token.label}
fieldSpec={fsa.maputnik.thunderforest_access_token} fieldSpec={fsa.maputnik.thunderforest_access_token}
data-wd-key="modal:settings.maputnik:thunderforest_access_token" data-wd-key="modal:settings.maputnik:thunderforest_access_token"
value={metadata["maputnik:thunderforest_access_token"]} value={metadata["maputnik:thunderforest_access_token"]}
onChange={(value) => onChangeMetadataProperty("maputnik:thunderforest_access_token", value)} onChange={(value) => onChangeMetadataProperty("maputnik:thunderforest_access_token", value)}
/> />
<FieldString <FieldString
label={fsa.maputnik.stadia_access_token.label} label={fsa.maputnik.stadia_access_token.label}
fieldSpec={fsa.maputnik.stadia_access_token} fieldSpec={fsa.maputnik.stadia_access_token}
data-wd-key="modal:settings.maputnik:stadia_access_token" data-wd-key="modal:settings.maputnik:stadia_access_token"
value={metadata["maputnik:stadia_access_token"]} value={metadata["maputnik:stadia_access_token"]}
onChange={(value) => onChangeMetadataProperty("maputnik:stadia_access_token", value)} onChange={(value) => onChangeMetadataProperty("maputnik:stadia_access_token", value)}
/> />
<FieldString <FieldString
label={fsa.maputnik.locationiq_access_token.label} label={fsa.maputnik.locationiq_access_token.label}
fieldSpec={fsa.maputnik.locationiq_access_token} fieldSpec={fsa.maputnik.locationiq_access_token}
data-wd-key="modal:settings.maputnik:locationiq_access_token" data-wd-key="modal:settings.maputnik:locationiq_access_token"
value={metadata["maputnik:locationiq_access_token"]} value={metadata["maputnik:locationiq_access_token"]}
onChange={(value) => onChangeMetadataProperty("maputnik:locationiq_access_token", value)} onChange={(value) => onChangeMetadataProperty("maputnik:locationiq_access_token", value)}
/> />
<FieldArray <FieldArray
label={t("Center")} label={t("Center")}
fieldSpec={latest.$root.center} fieldSpec={latest.$root.center}
length={2} length={2}
type="number" type="number"
value={mapStyle.center || []} value={mapStyle.center || []}
default={[0, 0]} default={[0, 0]}
onChange={(value) => changeStyleProperty("center", value)} onChange={(value) => this.changeStyleProperty("center", value)}
/> />
<FieldNumber <FieldNumber
label={t("Zoom")} label={t("Zoom")}
data-wd-key="modal:settings.zoom" data-wd-key="modal:settings.zoom"
fieldSpec={latest.$root.zoom} fieldSpec={latest.$root.zoom}
value={mapStyle.zoom} value={mapStyle.zoom}
default={0} default={0}
onChange={(value) => changeStyleProperty("zoom", value)} onChange={(value) => this.changeStyleProperty("zoom", value)}
/> />
<FieldNumber <FieldNumber
label={t("Bearing")} label={t("Bearing")}
data-wd-key="modal:settings.bearing" data-wd-key="modal:settings.bearing"
fieldSpec={latest.$root.bearing} fieldSpec={latest.$root.bearing}
value={mapStyle.bearing} value={mapStyle.bearing}
default={latest.$root.bearing.default} default={latest.$root.bearing.default}
onChange={(value) => changeStyleProperty("bearing", value)} onChange={(value) => this.changeStyleProperty("bearing", value)}
/> />
<FieldNumber <FieldNumber
label={t("Pitch")} label={t("Pitch")}
data-wd-key="modal:settings.pitch" data-wd-key="modal:settings.pitch"
fieldSpec={latest.$root.pitch} fieldSpec={latest.$root.pitch}
value={mapStyle.pitch} value={mapStyle.pitch}
default={latest.$root.pitch.default} default={latest.$root.pitch.default}
onChange={(value) => changeStyleProperty("pitch", value)} onChange={(value) => this.changeStyleProperty("pitch", value)}
/> />
<FieldEnum <FieldEnum
label={t("Light anchor")} label={t("Light anchor")}
fieldSpec={latest.light.anchor} fieldSpec={latest.light.anchor}
name="light-anchor" name="light-anchor"
value={light.anchor as string} value={light.anchor as string}
options={Object.keys(latest.light.anchor.values)} options={Object.keys(latest.light.anchor.values)}
default={latest.light.anchor.default} default={latest.light.anchor.default}
onChange={(value) => changeLightProperty("anchor", value)} onChange={(value) => this.changeLightProperty("anchor", value)}
/> />
<FieldColor <FieldColor
label={t("Light color")} label={t("Light color")}
fieldSpec={latest.light.color} fieldSpec={latest.light.color}
value={light.color as string} value={light.color as string}
default={latest.light.color.default} default={latest.light.color.default}
onChange={(value) => changeLightProperty("color", value)} onChange={(value) => this.changeLightProperty("color", value)}
/> />
<FieldNumber <FieldNumber
label={t("Light intensity")} label={t("Light intensity")}
data-wd-key="modal:settings.light-intensity" data-wd-key="modal:settings.light-intensity"
fieldSpec={latest.light.intensity} fieldSpec={latest.light.intensity}
value={light.intensity as number} value={light.intensity as number}
default={latest.light.intensity.default} default={latest.light.intensity.default}
onChange={(value) => changeLightProperty("intensity", value)} onChange={(value) => this.changeLightProperty("intensity", value)}
/> />
<FieldArray <FieldArray
label={t("Light position")} label={t("Light position")}
fieldSpec={latest.light.position} fieldSpec={latest.light.position}
type="number" type="number"
length={latest.light.position.length} length={latest.light.position.length}
value={light.position as number[]} value={light.position as number[]}
default={latest.light.position.default} default={latest.light.position.default}
onChange={(value) => changeLightProperty("position", value)} onChange={(value) => this.changeLightProperty("position", value)}
/> />
<FieldString <FieldString
label={t("Terrain source")} label={t("Terrain source")}
fieldSpec={latest.terrain.source} fieldSpec={latest.terrain.source}
data-wd-key="modal:settings.maputnik:terrain_source" data-wd-key="modal:settings.maputnik:terrain_source"
value={terrain.source} value={terrain.source}
onChange={(value) => changeTerrainProperty("source", value)} onChange={(value) => this.changeTerrainProperty("source", value)}
/> />
<FieldNumber <FieldNumber
label={t("Terrain exaggeration")} label={t("Terrain exaggeration")}
data-wd-key="modal:settings.terrain-exaggeration" data-wd-key="modal:settings.terrain-exaggeration"
fieldSpec={latest.terrain.exaggeration} fieldSpec={latest.terrain.exaggeration}
value={terrain.exaggeration} value={terrain.exaggeration}
default={latest.terrain.exaggeration.default} default={latest.terrain.exaggeration.default}
onChange={(value) => changeTerrainProperty("exaggeration", value)} onChange={(value) => this.changeTerrainProperty("exaggeration", value)}
/> />
<FieldNumber <FieldNumber
label={t("Transition delay")} label={t("Transition delay")}
data-wd-key="modal:settings.transition-delay" data-wd-key="modal:settings.transition-delay"
fieldSpec={latest.transition.delay} fieldSpec={latest.transition.delay}
value={transition.delay} value={transition.delay}
default={latest.transition.delay.default} default={latest.transition.delay.default}
onChange={(value) => changeTransitionProperty("delay", value)} onChange={(value) => this.changeTransitionProperty("delay", value)}
/> />
<FieldNumber <FieldNumber
label={t("Transition duration")} label={t("Transition duration")}
data-wd-key="modal:settings.transition-duration" data-wd-key="modal:settings.transition-duration"
fieldSpec={latest.transition.duration} fieldSpec={latest.transition.duration}
value={transition.duration} value={transition.duration}
default={latest.transition.duration.default} default={latest.transition.duration.default}
onChange={(value) => changeTransitionProperty("duration", value)} onChange={(value) => this.changeTransitionProperty("duration", value)}
/> />
<FieldSelect <FieldSelect
label={t("Projection")} label={t("Projection")}
data-wd-key="modal:settings.projection" data-wd-key="modal:settings.projection"
options={[ options={[
["", "Undefined"], ["", "Undefined"],
["mercator", "Mercator"], ["mercator", "Mercator"],
["globe", "Globe"], ["globe", "Globe"],
["vertical-perspective", "Vertical Perspective"] ["vertical-perspective", "Vertical Perspective"]
]} ]}
value={projection?.type?.toString() || ""} value={projection?.type?.toString() || ""}
onChange={(value) => changeProjectionType(value)} onChange={(value) => this.changeProjectionType(value)}
/> />
<FieldSelect <FieldSelect
label={fsa.maputnik.style_renderer.label} label={fsa.maputnik.style_renderer.label}
fieldSpec={fsa.maputnik.style_renderer} fieldSpec={fsa.maputnik.style_renderer}
data-wd-key="modal:settings.maputnik:renderer" data-wd-key="modal:settings.maputnik:renderer"
options={[ options={[
["mlgljs", "MapLibreGL JS"], ["mlgljs", "MapLibreGL JS"],
["ol", t("Open Layers (experimental)")], ["ol", t("Open Layers (experimental)")],
]} ]}
value={metadata["maputnik:renderer"] || "mlgljs"} value={metadata["maputnik:renderer"] || "mlgljs"}
onChange={(value) => onChangeMetadataProperty("maputnik:renderer", value)} onChange={(value) => onChangeMetadataProperty("maputnik:renderer", value)}
/> />
</div> </div>
</Modal>; </Modal>;
}; }
}
export const ModalSettings = withTranslation()(ModalSettingsInternal); export const ModalSettings = withTranslation()(ModalSettingsInternal);
+119 -117
View File
@@ -10,126 +10,128 @@ type ModalShortcutsInternalProps = {
} & WithTranslation; } & WithTranslation;
const ModalShortcutsInternal: React.FC<ModalShortcutsInternalProps> = (props) => { class ModalShortcutsInternal extends React.Component<ModalShortcutsInternalProps> {
const t = props.t; render() {
const help = [ const t = this.props.t;
{ const help = [
key: <kbd>?</kbd>, {
text: t("Shortcuts menu") key: <kbd>?</kbd>,
}, text: t("Shortcuts menu")
{ },
key: <kbd>o</kbd>, {
text: t("Open modal") key: <kbd>o</kbd>,
}, text: t("Open modal")
{ },
key: <kbd>e</kbd>, {
text: t("Export modal") key: <kbd>e</kbd>,
}, text: t("Export modal")
{ },
key: <kbd>d</kbd>, {
text: t("Data Sources modal") key: <kbd>d</kbd>,
}, text: t("Data Sources modal")
{ },
key: <kbd>s</kbd>, {
text: t("Style Settings modal") key: <kbd>s</kbd>,
}, text: t("Style Settings modal")
{ },
key: <kbd>i</kbd>, {
text: t("Toggle inspect") key: <kbd>i</kbd>,
}, text: t("Toggle inspect")
{ },
key: <kbd>m</kbd>, {
text: t("Focus map") key: <kbd>m</kbd>,
}, text: t("Focus map")
{ },
key: <kbd>!</kbd>, {
text: t("Debug modal") key: <kbd>!</kbd>,
}, text: t("Debug modal")
]; },
];
const mapShortcuts = [ const mapShortcuts = [
{ {
key: <kbd>+</kbd>, key: <kbd>+</kbd>,
text: t("Increase the zoom level by 1.",) text: t("Increase the zoom level by 1.",)
}, },
{ {
key: <><kbd>Shift</kbd> + <kbd>+</kbd></>, key: <><kbd>Shift</kbd> + <kbd>+</kbd></>,
text: t("Increase the zoom level by 2.",) text: t("Increase the zoom level by 2.",)
}, },
{ {
key: <kbd>-</kbd>, key: <kbd>-</kbd>,
text: t("Decrease the zoom level by 1.",) text: t("Decrease the zoom level by 1.",)
}, },
{ {
key: <><kbd>Shift</kbd> + <kbd>-</kbd></>, key: <><kbd>Shift</kbd> + <kbd>-</kbd></>,
text: t("Decrease the zoom level by 2.",) text: t("Decrease the zoom level by 2.",)
}, },
{ {
key: <kbd>Up</kbd>, key: <kbd>Up</kbd>,
text: t("Pan up by 100 pixels.",) text: t("Pan up by 100 pixels.",)
}, },
{ {
key: <kbd>Down</kbd>, key: <kbd>Down</kbd>,
text: t("Pan down by 100 pixels.",) text: t("Pan down by 100 pixels.",)
}, },
{ {
key: <kbd>Left</kbd>, key: <kbd>Left</kbd>,
text: t("Pan left by 100 pixels.",) text: t("Pan left by 100 pixels.",)
}, },
{ {
key: <kbd>Right</kbd>, key: <kbd>Right</kbd>,
text: t("Pan right by 100 pixels.",) text: t("Pan right by 100 pixels.",)
}, },
{ {
key: <><kbd>Shift</kbd> + <kbd>Right</kbd></>, key: <><kbd>Shift</kbd> + <kbd>Right</kbd></>,
text: t("Increase the rotation by 15 degrees.",) text: t("Increase the rotation by 15 degrees.",)
}, },
{ {
key: <><kbd>Shift</kbd> + <kbd>Left</kbd></>, key: <><kbd>Shift</kbd> + <kbd>Left</kbd></>,
text: t("Decrease the rotation by 15 degrees.") text: t("Decrease the rotation by 15 degrees.")
}, },
{ {
key: <><kbd>Shift</kbd> + <kbd>Up</kbd></>, key: <><kbd>Shift</kbd> + <kbd>Up</kbd></>,
text: t("Increase the pitch by 10 degrees.") text: t("Increase the pitch by 10 degrees.")
}, },
{ {
key: <><kbd>Shift</kbd> + <kbd>Down</kbd></>, key: <><kbd>Shift</kbd> + <kbd>Down</kbd></>,
text: t("Decrease the pitch by 10 degrees.") text: t("Decrease the pitch by 10 degrees.")
}, },
]; ];
return <Modal return <Modal
data-wd-key="modal:shortcuts" data-wd-key="modal:shortcuts"
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={props.onOpenToggle} onOpenToggle={this.props.onOpenToggle}
title={t("Shortcuts")} title={t("Shortcuts")}
> >
<section className="maputnik-modal-section maputnik-modal-shortcuts"> <section className="maputnik-modal-section maputnik-modal-shortcuts">
<p> <p>
<Trans t={t}> <Trans t={t}>
Press <code>ESC</code> to lose focus of any active elements, then press one of: Press <code>ESC</code> to lose focus of any active elements, then press one of:
</Trans> </Trans>
</p> </p>
<dl> <dl>
{help.map((item, idx) => { {help.map((item, idx) => {
return <div key={idx} className="maputnik-modal-shortcuts__shortcut"> return <div key={idx} className="maputnik-modal-shortcuts__shortcut">
<dt key={"dt"+idx}>{item.key}</dt> <dt key={"dt"+idx}>{item.key}</dt>
<dd key={"dd"+idx}>{item.text}</dd> <dd key={"dd"+idx}>{item.text}</dd>
</div>; </div>;
})} })}
</dl> </dl>
<p>{t("If the Map is in focused you can use the following shortcuts")}</p> <p>{t("If the Map is in focused you can use the following shortcuts")}</p>
<ul> <ul>
{mapShortcuts.map((item, idx) => { {mapShortcuts.map((item, idx) => {
return <li key={idx}> return <li key={idx}>
<span>{item.key}</span> {item.text} <span>{item.key}</span> {item.text}
</li>; </li>;
})} })}
</ul> </ul>
</section> </section>
</Modal>; </Modal>;
}; }
}
export const ModalShortcuts = withTranslation()(ModalShortcutsInternal); export const ModalShortcuts = withTranslation()(ModalShortcutsInternal);
+262 -245
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
import {MdAddCircleOutline, MdDelete} from "react-icons/md"; import {MdAddCircleOutline, MdDelete} from "react-icons/md";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import type {GeoJSONSourceSpecification, RasterDEMSourceSpecification, RasterSourceSpecification, SourceSpecification, VectorSourceSpecification} from "maplibre-gl"; import type {GeoJSONSourceSpecification, RasterDEMSourceSpecification, RasterSourceSpecification, SourceSpecification, VectorSourceSpecification} from "maplibre-gl";
@@ -23,21 +23,23 @@ type PublicSourceProps = {
onSelect(...args: unknown[]): unknown onSelect(...args: unknown[]): unknown
}; };
const PublicSource: React.FC<PublicSourceProps> = (props) => { class PublicSource extends React.Component<PublicSourceProps> {
return <div className="maputnik-public-source"> render() {
<InputButton return <div className="maputnik-public-source">
className="maputnik-public-source-select" <InputButton
onClick={() => props.onSelect(props.id)} className="maputnik-public-source-select"
> onClick={() => this.props.onSelect(this.props.id)}
<div className="maputnik-public-source-info"> >
<p className="maputnik-public-source-name">{props.title}</p> <div className="maputnik-public-source-info">
<p className="maputnik-public-source-id">#{props.id}</p> <p className="maputnik-public-source-name">{this.props.title}</p>
</div> <p className="maputnik-public-source-id">#{this.props.id}</p>
<span className="maputnik-space" /> </div>
<MdAddCircleOutline /> <span className="maputnik-space" />
</InputButton> <MdAddCircleOutline />
</div>; </InputButton>
}; </div>;
}
}
function editorMode(source: SourceSpecification) { function editorMode(source: SourceSpecification) {
if(source.type === "raster") { if(source.type === "raster") {
@@ -77,184 +79,197 @@ type ActiveModalSourcesTypeEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const ActiveModalSourcesTypeEditor: React.FC<ActiveModalSourcesTypeEditorProps> = (props) => { class ActiveModalSourcesTypeEditor extends React.Component<ActiveModalSourcesTypeEditorProps> {
const t = props.t; render() {
return <div className="maputnik-active-source-type-editor"> const t = this.props.t;
<div className="maputnik-active-source-type-editor-header"> return <div className="maputnik-active-source-type-editor">
<span className="maputnik-active-source-type-editor-header-id">#{props.sourceId}</span> <div className="maputnik-active-source-type-editor-header">
<span className="maputnik-space" /> <span className="maputnik-active-source-type-editor-header-id">#{this.props.sourceId}</span>
<InputButton <span className="maputnik-space" />
aria-label={t("Remove '{{sourceId}}' source", {sourceId: props.sourceId})} <InputButton
className="maputnik-active-source-type-editor-header-delete" aria-label={t("Remove '{{sourceId}}' source", {sourceId: this.props.sourceId})}
onClick={()=> props.onDelete(props.sourceId)} className="maputnik-active-source-type-editor-header-delete"
style={{backgroundColor: "transparent"}} onClick={()=> this.props.onDelete(this.props.sourceId)}
> style={{backgroundColor: "transparent"}}
<MdDelete /> >
</InputButton> <MdDelete />
</div> </InputButton>
<div className="maputnik-active-source-type-editor-content"> </div>
<ModalSourcesTypeEditor <div className="maputnik-active-source-type-editor-content">
onChange={props.onChange} <ModalSourcesTypeEditor
mode={editorMode(props.source)} onChange={this.props.onChange}
source={props.source} mode={editorMode(this.props.source)}
/> source={this.props.source}
</div> />
</div>; </div>
}; </div>;
}
}
type AddSourceProps = { type AddSourceProps = {
onAdd(...args: unknown[]): unknown onAdd(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
function defaultSource(mode: EditorMode, currentSource: SourceSpecification | undefined): SourceSpecification { type AddSourceState = {
const source = currentSource || {}; mode: EditorMode
const {protocol} = window.location; sourceId: string
source: SourceSpecification
};
switch(mode) { class AddSource extends React.Component<AddSourceProps, AddSourceState> {
case "pmtiles_vector": return { constructor(props: AddSourceProps) {
type: "vector", super(props);
url: `${protocol}//localhost:3000/file.pmtiles` this.state = {
mode: "tilejson_vector",
sourceId: generateId(),
source: this.defaultSource("tilejson_vector"),
}; };
case "geojson_url": return { }
type: "geojson",
data: `${protocol}//localhost:3000/geojson.json` defaultSource(mode: EditorMode): SourceSpecification {
const source = (this.state || {}).source || {};
const {protocol} = window.location;
switch(mode) {
case "pmtiles_vector": return {
type: "vector",
url: `${protocol}//localhost:3000/file.pmtiles`
};
case "geojson_url": return {
type: "geojson",
data: `${protocol}//localhost:3000/geojson.json`
};
case "geojson_json": return {
type: "geojson",
cluster: (source as GeoJSONSourceSpecification).cluster || false,
data: ""
};
case "tilejson_vector": return {
type: "vector",
url: (source as VectorSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
};
case "tile_vector": return {
type: "vector",
tiles: (source as VectorSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.pbf`],
minzoom: (source as VectorSourceSpecification).minzoom || 0,
maxzoom: (source as VectorSourceSpecification).maxzoom || 14,
scheme: (source as VectorSourceSpecification).scheme || "xyz"
};
case "tilejson_raster": return {
type: "raster",
url: (source as RasterSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
};
case "tile_raster": return {
type: "raster",
tiles: (source as RasterSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`],
minzoom: (source as RasterSourceSpecification).minzoom || 0,
maxzoom: (source as RasterSourceSpecification).maxzoom || 14,
scheme: (source as RasterSourceSpecification).scheme || "xyz",
tileSize: (source as RasterSourceSpecification).tileSize || 512,
};
case "tilejson_raster-dem": return {
type: "raster-dem",
url: (source as RasterDEMSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
};
case "tilexyz_raster-dem": return {
type: "raster-dem",
tiles: (source as RasterDEMSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`],
minzoom: (source as RasterDEMSourceSpecification).minzoom || 0,
maxzoom: (source as RasterDEMSourceSpecification).maxzoom || 14,
tileSize: (source as RasterDEMSourceSpecification).tileSize || 512
};
case "image": return {
type: "image",
url: `${protocol}//localhost:3000/image.png`,
coordinates: [
[0,0],
[0,0],
[0,0],
[0,0],
],
};
case "video": return {
type: "video",
urls: [
`${protocol}//localhost:3000/movie.mp4`
],
coordinates: [
[0,0],
[0,0],
[0,0],
[0,0],
],
};
default: return {} as any;
}
}
onAdd = () => {
const {source, sourceId} = this.state;
this.props.onAdd(sourceId, source);
};
onChangeSource = (source: SourceSpecification) => {
this.setState({source});
};
render() {
const t = this.props.t;
// Kind of a hack because the type changes, however maputnik has 1..n
// options per type, for example
//
// - 'geojson' - 'GeoJSON (URL)' and 'GeoJSON (JSON)'
// - 'raster' - 'Raster (TileJSON URL)' and 'Raster (XYZ URL)'
//
// So we just ignore the values entirely as they are self explanatory
const sourceTypeFieldSpec = {
doc: latest.source_vector.type.doc
}; };
case "geojson_json": return {
type: "geojson", return <div className="maputnik-add-source">
cluster: (source as GeoJSONSourceSpecification).cluster || false, <FieldString
data: "" label={t("Source ID")}
}; fieldSpec={{doc: t("Unique ID that identifies the source and is used in the layer to reference the source.")}}
case "tilejson_vector": return { value={this.state.sourceId}
type: "vector", onChange={(v: string) => this.setState({ sourceId: v})}
url: (source as VectorSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json` data-wd-key="modal:sources.add.source_id"
}; />
case "tile_vector": return { <FieldSelect
type: "vector", label={t("Source Type")}
tiles: (source as VectorSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.pbf`], fieldSpec={sourceTypeFieldSpec}
minzoom: (source as VectorSourceSpecification).minzoom || 0, options={[
maxzoom: (source as VectorSourceSpecification).maxzoom || 14, ["geojson_json", t("GeoJSON (JSON)")],
scheme: (source as VectorSourceSpecification).scheme || "xyz" ["geojson_url", t("GeoJSON (URL)")],
}; ["tilejson_vector", t("Vector (TileJSON URL)")],
case "tilejson_raster": return { ["tile_vector", t("Vector (Tile URLs)")],
type: "raster", ["tilejson_raster", t("Raster (TileJSON URL)")],
url: (source as RasterSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json` ["tile_raster", t("Raster (Tile URLs)")],
}; ["tilejson_raster-dem", t("Raster DEM (TileJSON URL)")],
case "tile_raster": return { ["tilexyz_raster-dem", t("Raster DEM (XYZ URLs)")],
type: "raster", ["pmtiles_vector", t("Vector (PMTiles)")],
tiles: (source as RasterSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`], ["image", t("Image")],
minzoom: (source as RasterSourceSpecification).minzoom || 0, ["video", t("Video")],
maxzoom: (source as RasterSourceSpecification).maxzoom || 14, ]}
scheme: (source as RasterSourceSpecification).scheme || "xyz", onChange={mode => this.setState({mode: mode as EditorMode, source: this.defaultSource(mode as EditorMode)})}
tileSize: (source as RasterSourceSpecification).tileSize || 512, value={this.state.mode as string}
}; data-wd-key="modal:sources.add.source_type"
case "tilejson_raster-dem": return { />
type: "raster-dem", <ModalSourcesTypeEditor
url: (source as RasterDEMSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json` onChange={this.onChangeSource}
}; mode={this.state.mode}
case "tilexyz_raster-dem": return { source={this.state.source}
type: "raster-dem", />
tiles: (source as RasterDEMSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`], <InputButton
minzoom: (source as RasterDEMSourceSpecification).minzoom || 0, className="maputnik-add-source-button"
maxzoom: (source as RasterDEMSourceSpecification).maxzoom || 14, onClick={this.onAdd}
tileSize: (source as RasterDEMSourceSpecification).tileSize || 512 data-wd-key="modal:sources.add.add_source"
}; >
case "image": return { {t("Add Source")}
type: "image", </InputButton>
url: `${protocol}//localhost:3000/image.png`, </div>;
coordinates: [
[0,0],
[0,0],
[0,0],
[0,0],
],
};
case "video": return {
type: "video",
urls: [
`${protocol}//localhost:3000/movie.mp4`
],
coordinates: [
[0,0],
[0,0],
[0,0],
[0,0],
],
};
default: return {} as any;
} }
} }
const AddSource: React.FC<AddSourceProps> = (props) => {
const [mode, setMode] = useState<EditorMode>("tilejson_vector");
const [sourceId, setSourceId] = useState<string>(() => generateId());
const [source, setSource] = useState<SourceSpecification>(() => defaultSource("tilejson_vector", undefined));
const onAdd = () => {
props.onAdd(sourceId, source);
};
const onChangeSource = (newSource: SourceSpecification) => {
setSource(newSource);
};
const t = props.t;
// Kind of a hack because the type changes, however maputnik has 1..n
// options per type, for example
//
// - 'geojson' - 'GeoJSON (URL)' and 'GeoJSON (JSON)'
// - 'raster' - 'Raster (TileJSON URL)' and 'Raster (XYZ URL)'
//
// So we just ignore the values entirely as they are self explanatory
const sourceTypeFieldSpec = {
doc: latest.source_vector.type.doc
};
return <div className="maputnik-add-source">
<FieldString
label={t("Source ID")}
fieldSpec={{doc: t("Unique ID that identifies the source and is used in the layer to reference the source.")}}
value={sourceId}
onChange={(v: string) => setSourceId(v)}
data-wd-key="modal:sources.add.source_id"
/>
<FieldSelect
label={t("Source Type")}
fieldSpec={sourceTypeFieldSpec}
options={[
["geojson_json", t("GeoJSON (JSON)")],
["geojson_url", t("GeoJSON (URL)")],
["tilejson_vector", t("Vector (TileJSON URL)")],
["tile_vector", t("Vector (Tile URLs)")],
["tilejson_raster", t("Raster (TileJSON URL)")],
["tile_raster", t("Raster (Tile URLs)")],
["tilejson_raster-dem", t("Raster DEM (TileJSON URL)")],
["tilexyz_raster-dem", t("Raster DEM (XYZ URLs)")],
["pmtiles_vector", t("Vector (PMTiles)")],
["image", t("Image")],
["video", t("Video")],
]}
onChange={newMode => {
setMode(newMode as EditorMode);
setSource(defaultSource(newMode as EditorMode, source));
}}
value={mode as string}
data-wd-key="modal:sources.add.source_type"
/>
<ModalSourcesTypeEditor
onChange={onChangeSource}
mode={mode}
source={source}
/>
<InputButton
className="maputnik-add-source-button"
onClick={onAdd}
data-wd-key="modal:sources.add.add_source"
>
{t("Add Source")}
</InputButton>
</div>;
};
type ModalSourcesInternalProps = { type ModalSourcesInternalProps = {
mapStyle: StyleSpecificationWithId mapStyle: StyleSpecificationWithId
isOpen: boolean isOpen: boolean
@@ -262,68 +277,70 @@ type ModalSourcesInternalProps = {
onStyleChanged: OnStyleChangedCallback onStyleChanged: OnStyleChangedCallback
} & WithTranslation; } & WithTranslation;
function stripTitle(source: SourceSpecification & {title?: string}): SourceSpecification { class ModalSourcesInternal extends React.Component<ModalSourcesInternalProps> {
const strippedSource = {...source}; stripTitle(source: SourceSpecification & {title?: string}): SourceSpecification {
delete strippedSource["title"]; const strippedSource = {...source};
return strippedSource; delete strippedSource["title"];
return strippedSource;
}
render() {
const {t, mapStyle} = this.props;
const i18nProps = {t, i18n: this.props.i18n, tReady: this.props.tReady};
const activeSources = Object.keys(mapStyle.sources).map(sourceId => {
const source = mapStyle.sources[sourceId];
return <ActiveModalSourcesTypeEditor
key={sourceId}
sourceId={sourceId}
source={source}
onChange={(src: SourceSpecification) => this.props.onStyleChanged(changeSource(mapStyle, sourceId, src))}
onDelete={() => this.props.onStyleChanged(deleteSource(mapStyle, sourceId))}
{...i18nProps}
/>;
});
const tilesetOptions = Object.keys(publicSources).filter((sourceId: string) => !(sourceId in mapStyle.sources)).map((sourceId: string) => {
const source = publicSources[sourceId as keyof typeof publicSources] as SourceSpecification & {title: string};
return <PublicSource
key={sourceId}
id={sourceId}
type={source.type}
title={source.title}
onSelect={() => this.props.onStyleChanged(addSource(mapStyle, sourceId, this.stripTitle(source)))}
/>;
});
return <Modal
data-wd-key="modal:sources"
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={t("Sources")}
>
<section className="maputnik-modal-section">
<h1>{t("Active Sources")}</h1>
{activeSources}
</section>
<section className="maputnik-modal-section">
<h1>{t("Choose Public Source")}</h1>
<p>
{t("Add one of the publicly available sources to your style.")}
</p>
<div className="maputnik-public-sources" style={{maxWidth: 500}}>
{tilesetOptions}
</div>
</section>
<section className="maputnik-modal-section">
<h1>{t("Add New Source")}</h1>
<p>{t("Add a new source to your style. You can only choose the source type and id at creation time!")}</p>
<AddSource
onAdd={(sourceId: string, source: SourceSpecification) => this.props.onStyleChanged(addSource(mapStyle, sourceId, source))}
{...i18nProps}
/>
</section>
</Modal>;
}
} }
const ModalSourcesInternal: React.FC<ModalSourcesInternalProps> = (props) => {
const {t, mapStyle} = props;
const i18nProps = {t, i18n: props.i18n, tReady: props.tReady};
const activeSources = Object.keys(mapStyle.sources).map(sourceId => {
const source = mapStyle.sources[sourceId];
return <ActiveModalSourcesTypeEditor
key={sourceId}
sourceId={sourceId}
source={source}
onChange={(src: SourceSpecification) => props.onStyleChanged(changeSource(mapStyle, sourceId, src))}
onDelete={() => props.onStyleChanged(deleteSource(mapStyle, sourceId))}
{...i18nProps}
/>;
});
const tilesetOptions = Object.keys(publicSources).filter((sourceId: string) => !(sourceId in mapStyle.sources)).map((sourceId: string) => {
const source = publicSources[sourceId as keyof typeof publicSources] as SourceSpecification & {title: string};
return <PublicSource
key={sourceId}
id={sourceId}
type={source.type}
title={source.title}
onSelect={() => props.onStyleChanged(addSource(mapStyle, sourceId, stripTitle(source)))}
/>;
});
return <Modal
data-wd-key="modal:sources"
isOpen={props.isOpen}
onOpenToggle={props.onOpenToggle}
title={t("Sources")}
>
<section className="maputnik-modal-section">
<h1>{t("Active Sources")}</h1>
{activeSources}
</section>
<section className="maputnik-modal-section">
<h1>{t("Choose Public Source")}</h1>
<p>
{t("Add one of the publicly available sources to your style.")}
</p>
<div className="maputnik-public-sources" style={{maxWidth: 500}}>
{tilesetOptions}
</div>
</section>
<section className="maputnik-modal-section">
<h1>{t("Add New Source")}</h1>
<p>{t("Add a new source to your style. You can only choose the source type and id at creation time!")}</p>
<AddSource
onAdd={(sourceId: string, source: SourceSpecification) => props.onStyleChanged(addSource(mapStyle, sourceId, source))}
{...i18nProps}
/>
</section>
</Modal>;
};
export const ModalSources = withTranslation()(ModalSourcesInternal); export const ModalSources = withTranslation()(ModalSourcesInternal);
+259 -242
View File
@@ -24,21 +24,23 @@ type TileJSONSourceEditorProps = {
} & WithTranslation; } & WithTranslation;
const TileJSONSourceEditor: React.FC<TileJSONSourceEditorProps> = (props) => { class TileJSONSourceEditor extends React.Component<TileJSONSourceEditorProps> {
const t = props.t; render() {
return <div> const t = this.props.t;
<FieldUrl return <div>
label={t("TileJSON URL")} <FieldUrl
fieldSpec={latest.source_vector.url} label={t("TileJSON URL")}
value={props.source.url} fieldSpec={latest.source_vector.url}
onChange={url => props.onChange({ value={this.props.source.url}
...props.source, onChange={url => this.props.onChange({
url: url ...this.props.source,
})} url: url
/> })}
{props.children} />
</div>; {this.props.children}
}; </div>;
}
}
type TileURLSourceEditorProps = { type TileURLSourceEditorProps = {
source: { source: {
@@ -51,63 +53,66 @@ type TileURLSourceEditorProps = {
children?: React.ReactNode children?: React.ReactNode
} & WithTranslation; } & WithTranslation;
const TileURLSourceEditor: React.FC<TileURLSourceEditorProps> = (props) => { class TileURLSourceEditor extends React.Component<TileURLSourceEditorProps> {
function changeTileUrls(tiles: string[]) { changeTileUrls(tiles: string[]) {
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
tiles, tiles,
}); });
} }
function renderTileUrls() { renderTileUrls() {
const tiles = props.source.tiles || []; const tiles = this.props.source.tiles || [];
return <FieldDynamicArray return <FieldDynamicArray
label={props.t("Tile URL")} label={this.props.t("Tile URL")}
fieldSpec={latest.source_vector.tiles} fieldSpec={latest.source_vector.tiles}
type="url" type="url"
value={tiles} value={tiles}
onChange={changeTileUrls} onChange={this.changeTileUrls.bind(this)}
/>; />;
} }
const t = props.t; render() {
return <div> const t = this.props.t;
{renderTileUrls()} return <div>
<FieldSelect {this.renderTileUrls()}
label={t("Scheme Type")} <FieldSelect
fieldSpec={latest.source_vector.scheme} label={t("Scheme Type")}
options={[ fieldSpec={latest.source_vector.scheme}
["xyz", "xyz (Slippy map tilenames scheme)"], options={[
["tms", "tms (OSGeo spec scheme)"], ["xyz", "xyz (Slippy map tilenames scheme)"],
]} ["tms", "tms (OSGeo spec scheme)"],
onChange={scheme => props.onChange({ ]}
...props.source, onChange={scheme => this.props.onChange({
scheme ...this.props.source,
})} scheme
value={props.source.scheme} })}
data-wd-key="modal:sources.add.scheme_type" value={this.props.source.scheme}
/> data-wd-key="modal:sources.add.scheme_type"
<FieldNumber />
label={t("Min Zoom")} <FieldNumber
fieldSpec={latest.source_vector.minzoom} label={t("Min Zoom")}
value={props.source.minzoom || 0} fieldSpec={latest.source_vector.minzoom}
onChange={minzoom => props.onChange({ value={this.props.source.minzoom || 0}
...props.source, onChange={minzoom => this.props.onChange({
minzoom: minzoom ...this.props.source,
})} minzoom: minzoom
/> })}
<FieldNumber />
label={t("Max Zoom")} <FieldNumber
fieldSpec={latest.source_vector.maxzoom} label={t("Max Zoom")}
value={props.source.maxzoom || 22} fieldSpec={latest.source_vector.maxzoom}
onChange={maxzoom => props.onChange({ value={this.props.source.maxzoom || 22}
...props.source, onChange={maxzoom => this.props.onChange({
maxzoom: maxzoom ...this.props.source,
})} maxzoom: maxzoom
/> })}
{props.children} />
</div>; {this.props.children}
}; </div>;
}
}
const createCornerLabels: (t: TFunction) => { label: string, key: string }[] = (t) => ([ const createCornerLabels: (t: TFunction) => { label: string, key: string }[] = (t) => ([
{ label: t("Coord top left"), key: "top left" }, { label: t("Coord top left"), key: "top left" },
@@ -124,43 +129,45 @@ type ImageSourceEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const ImageSourceEditor: React.FC<ImageSourceEditorProps> = (props) => { class ImageSourceEditor extends React.Component<ImageSourceEditorProps> {
const t = props.t; render() {
const changeCoord = (idx: number, val: [number, number]) => { const t = this.props.t;
const coordinates = props.source.coordinates.slice(0); const changeCoord = (idx: number, val: [number, number]) => {
coordinates[idx] = val; const coordinates = this.props.source.coordinates.slice(0);
coordinates[idx] = val;
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
coordinates, coordinates,
}); });
}; };
return <div> return <div>
<FieldUrl <FieldUrl
label={t("Image URL")} label={t("Image URL")}
fieldSpec={latest.source_image.url} fieldSpec={latest.source_image.url}
value={props.source.url} value={this.props.source.url}
onChange={url => props.onChange({ onChange={url => this.props.onChange({
...props.source, ...this.props.source,
url, url,
})}
/>
{createCornerLabels(t).map(({label, key}, idx) => {
return (
<FieldArray
label={label}
key={key}
length={2}
type="number"
value={this.props.source.coordinates[idx]}
default={[0, 0]}
onChange={(val: [number, number]) => changeCoord(idx, val)}
/>
);
})} })}
/> </div>;
{createCornerLabels(t).map(({label, key}, idx) => { }
return ( }
<FieldArray
label={label}
key={key}
length={2}
type="number"
value={props.source.coordinates[idx]}
default={[0, 0]}
onChange={(val: [number, number]) => changeCoord(idx, val)}
/>
);
})}
</div>;
};
type VideoSourceEditorProps = { type VideoSourceEditorProps = {
source: { source: {
@@ -170,49 +177,51 @@ type VideoSourceEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const VideoSourceEditor: React.FC<VideoSourceEditorProps> = (props) => { class VideoSourceEditor extends React.Component<VideoSourceEditorProps> {
const t = props.t; render() {
const changeCoord = (idx: number, val: [number, number]) => { const t = this.props.t;
const coordinates = props.source.coordinates.slice(0); const changeCoord = (idx: number, val: [number, number]) => {
coordinates[idx] = val; const coordinates = this.props.source.coordinates.slice(0);
coordinates[idx] = val;
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
coordinates, coordinates,
}); });
}; };
const changeUrls = (urls: string[]) => { const changeUrls = (urls: string[]) => {
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
urls, urls,
}); });
}; };
return <div> return <div>
<FieldDynamicArray <FieldDynamicArray
label={t("Video URL")} label={t("Video URL")}
fieldSpec={latest.source_video.urls} fieldSpec={latest.source_video.urls}
type="string" type="string"
value={props.source.urls} value={this.props.source.urls}
default={[]} default={[]}
onChange={changeUrls} onChange={changeUrls}
/> />
{createCornerLabels(t).map(({label, key}, idx) => { {createCornerLabels(t).map(({label, key}, idx) => {
return ( return (
<FieldArray <FieldArray
label={label} label={label}
key={key} key={key}
length={2} length={2}
type="number" type="number"
value={props.source.coordinates[idx]} value={this.props.source.coordinates[idx]}
default={[0, 0]} default={[0, 0]}
onChange={(val: [number, number]) => changeCoord(idx, val)} onChange={(val: [number, number]) => changeCoord(idx, val)}
/> />
); );
})} })}
</div>; </div>;
}; }
}
type GeoJSONSourceUrlEditorProps = { type GeoJSONSourceUrlEditorProps = {
source: { source: {
@@ -221,18 +230,20 @@ type GeoJSONSourceUrlEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const GeoJSONSourceUrlEditor: React.FC<GeoJSONSourceUrlEditorProps> = (props) => { class GeoJSONSourceUrlEditor extends React.Component<GeoJSONSourceUrlEditorProps> {
const t = props.t; render() {
return <FieldUrl const t = this.props.t;
label={t("GeoJSON URL")} return <FieldUrl
fieldSpec={latest.source_geojson.data} label={t("GeoJSON URL")}
value={props.source.data} fieldSpec={latest.source_geojson.data}
onChange={data => props.onChange({ value={this.props.source.data}
...props.source, onChange={data => this.props.onChange({
data: data ...this.props.source,
})} data: data
/>; })}
}; />;
}
}
type GeoJSONSourceFieldJsonEditorProps = { type GeoJSONSourceFieldJsonEditorProps = {
source: { source: {
@@ -242,33 +253,35 @@ type GeoJSONSourceFieldJsonEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const GeoJSONSourceFieldJsonEditor: React.FC<GeoJSONSourceFieldJsonEditorProps> = (props) => { class GeoJSONSourceFieldJsonEditor extends React.Component<GeoJSONSourceFieldJsonEditorProps> {
const t = props.t; render() {
return <div> const t = this.props.t;
<Block label={t("GeoJSON")} fieldSpec={latest.source_geojson.data}> return <div>
<FieldJson <Block label={t("GeoJSON")} fieldSpec={latest.source_geojson.data}>
value={props.source.data} <FieldJson
lintType="json" value={this.props.source.data}
onChange={data => { lintType="json"
props.onChange({ onChange={data => {
...props.source, this.props.onChange({
data, ...this.props.source,
data,
});
}}
/>
</Block>
<FieldCheckbox
label={t("Cluster")}
value={this.props.source.cluster}
onChange={cluster => {
this.props.onChange({
...this.props.source,
cluster: cluster,
}); });
}} }}
/> />
</Block> </div>;
<FieldCheckbox }
label={t("Cluster")} }
value={props.source.cluster}
onChange={cluster => {
props.onChange({
...props.source,
cluster: cluster,
});
}}
/>
</div>;
};
type PMTilesSourceEditorProps = { type PMTilesSourceEditorProps = {
source: { source: {
@@ -278,22 +291,24 @@ type PMTilesSourceEditorProps = {
children?: React.ReactNode children?: React.ReactNode
} & WithTranslation; } & WithTranslation;
const PMTilesSourceEditor: React.FC<PMTilesSourceEditorProps> = (props) => { class PMTilesSourceEditor extends React.Component<PMTilesSourceEditorProps> {
const t = props.t; render() {
return <div> const t = this.props.t;
<FieldUrl return <div>
label={t("PMTiles URL")} <FieldUrl
fieldSpec={latest.source_vector.url} label={t("PMTiles URL")}
value={props.source.url} fieldSpec={latest.source_vector.url}
data-wd-key="modal:sources.add.source_url" value={this.props.source.url}
onChange={(url: string) => props.onChange({ data-wd-key="modal:sources.add.source_url"
...props.source, onChange={(url: string) => this.props.onChange({
url: url.startsWith("pmtiles://") ? url : `pmtiles://${url}` ...this.props.source,
})} url: url.startsWith("pmtiles://") ? url : `pmtiles://${url}`
/> })}
{props.children} />
</div>; {this.props.children}
}; </div>;
}
}
type ModalSourcesTypeEditorInternalProps = { type ModalSourcesTypeEditorInternalProps = {
mode: EditorMode mode: EditorMode
@@ -301,61 +316,63 @@ type ModalSourcesTypeEditorInternalProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const ModalSourcesTypeEditorInternal: React.FC<ModalSourcesTypeEditorInternalProps> = (props) => { class ModalSourcesTypeEditorInternal extends React.Component<ModalSourcesTypeEditorInternalProps> {
const t = props.t; render() {
const commonProps = { const t = this.props.t;
source: props.source, const commonProps = {
onChange: props.onChange, source: this.props.source,
t: props.t, onChange: this.props.onChange,
i18n: props.i18n, t: this.props.t,
tReady: props.tReady, i18n: this.props.i18n,
}; tReady: this.props.tReady,
switch(props.mode) { };
case "geojson_url": return <GeoJSONSourceUrlEditor {...commonProps} />; switch(this.props.mode) {
case "geojson_json": return <GeoJSONSourceFieldJsonEditor {...commonProps} />; case "geojson_url": return <GeoJSONSourceUrlEditor {...commonProps} />;
case "tilejson_vector": return <TileJSONSourceEditor {...commonProps} />; case "geojson_json": return <GeoJSONSourceFieldJsonEditor {...commonProps} />;
case "tile_vector": return <TileURLSourceEditor {...commonProps} />; case "tilejson_vector": return <TileJSONSourceEditor {...commonProps} />;
case "tilejson_raster": return <TileJSONSourceEditor {...commonProps} />; case "tile_vector": return <TileURLSourceEditor {...commonProps} />;
case "tile_raster": return <TileURLSourceEditor {...commonProps}> case "tilejson_raster": return <TileJSONSourceEditor {...commonProps} />;
<FieldNumber case "tile_raster": return <TileURLSourceEditor {...commonProps}>
label={t("Tile Size")} <FieldNumber
fieldSpec={latest.source_raster.tileSize} label={t("Tile Size")}
onChange={tileSize => props.onChange({ fieldSpec={latest.source_raster.tileSize}
...props.source, onChange={tileSize => this.props.onChange({
tileSize: tileSize ...this.props.source,
})} tileSize: tileSize
value={props.source.tileSize || latest.source_raster.tileSize.default} })}
data-wd-key="modal:sources.add.tile_size" value={this.props.source.tileSize || latest.source_raster.tileSize.default}
/> data-wd-key="modal:sources.add.tile_size"
</TileURLSourceEditor>; />
case "tilejson_raster-dem": return <TileJSONSourceEditor {...commonProps} />; </TileURLSourceEditor>;
case "tilexyz_raster-dem": return <TileURLSourceEditor {...commonProps}> case "tilejson_raster-dem": return <TileJSONSourceEditor {...commonProps} />;
<FieldNumber case "tilexyz_raster-dem": return <TileURLSourceEditor {...commonProps}>
label={t("Tile Size")} <FieldNumber
fieldSpec={latest.source_raster_dem.tileSize} label={t("Tile Size")}
onChange={tileSize => props.onChange({ fieldSpec={latest.source_raster_dem.tileSize}
...props.source, onChange={tileSize => this.props.onChange({
tileSize: tileSize ...this.props.source,
})} tileSize: tileSize
value={props.source.tileSize || latest.source_raster_dem.tileSize.default} })}
data-wd-key="modal:sources.add.tile_size" value={this.props.source.tileSize || latest.source_raster_dem.tileSize.default}
/> data-wd-key="modal:sources.add.tile_size"
<FieldSelect />
label={t("Encoding")} <FieldSelect
fieldSpec={latest.source_raster_dem.encoding} label={t("Encoding")}
options={Object.keys(latest.source_raster_dem.encoding.values)} fieldSpec={latest.source_raster_dem.encoding}
onChange={encoding => props.onChange({ options={Object.keys(latest.source_raster_dem.encoding.values)}
...props.source, onChange={encoding => this.props.onChange({
encoding: encoding ...this.props.source,
})} encoding: encoding
value={props.source.encoding || latest.source_raster_dem.encoding.default} })}
/> value={this.props.source.encoding || latest.source_raster_dem.encoding.default}
</TileURLSourceEditor>; />
case "pmtiles_vector": return <PMTilesSourceEditor {...commonProps} />; </TileURLSourceEditor>;
case "image": return <ImageSourceEditor {...commonProps} />; case "pmtiles_vector": return <PMTilesSourceEditor {...commonProps} />;
case "video": return <VideoSourceEditor {...commonProps} />; case "image": return <ImageSourceEditor {...commonProps} />;
default: return null; case "video": return <VideoSourceEditor {...commonProps} />;
default: return null;
}
} }
}; }
export const ModalSourcesTypeEditor = withTranslation()(ModalSourcesTypeEditorInternal); export const ModalSourcesTypeEditor = withTranslation()(ModalSourcesTypeEditorInternal);
+1 -1
View File
@@ -119,7 +119,7 @@ function createMaplibreExpressionLinter(spec?: StylePropertySpecification) {
const text = view.state.doc.toString(); const text = view.state.doc.toString();
const parsedJson = JSON.parse(text); const parsedJson = JSON.parse(text);
const ast = jsonToAst(text); const ast = jsonToAst(text);
const out = expression.createExpression(parsedJson, spec); const out = expression.createExpression(parsedJson, "expression", spec);
if (out?.result !== "error") { if (out?.result !== "error") {
return []; return [];
} }
+2 -2
View File
@@ -5,14 +5,14 @@ import { type LayerSpecification } from "maplibre-gl";
export function changeType(layer: LayerSpecification, newType: string): LayerSpecification { export function changeType(layer: LayerSpecification, newType: string): LayerSpecification {
const changedPaintProps: LayerSpecification["paint"] = { ...layer.paint }; const changedPaintProps: LayerSpecification["paint"] = { ...layer.paint };
Object.keys(changedPaintProps).forEach(propertyName => { Object.keys(changedPaintProps).forEach(propertyName => {
if(!(propertyName in latest["paint_" + newType])) { if(!(propertyName in ((latest["paint_" + newType as keyof typeof latest]) as string[]))) {
delete changedPaintProps[propertyName as keyof LayerSpecification["paint"]]; delete changedPaintProps[propertyName as keyof LayerSpecification["paint"]];
} }
}); });
const changedLayoutProps: LayerSpecification["layout"] = { ...layer.layout }; const changedLayoutProps: LayerSpecification["layout"] = { ...layer.layout };
Object.keys(changedLayoutProps).forEach(propertyName => { Object.keys(changedLayoutProps).forEach(propertyName => {
if(!(propertyName in latest["layout_" + newType])) { if(!(propertyName in ((latest["layout_" + newType as keyof typeof latest]) as string[]))) {
delete changedLayoutProps[propertyName as keyof LayerSpecification["layout"]]; delete changedLayoutProps[propertyName as keyof LayerSpecification["layout"]];
} }
}); });