@studiotitta/studio-editori 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { draftMode } from "next/headers";
3
+ import { rivinvaihdoin } from "./rivinvaihdot.js";
3
4
  export async function Muokattava({ kentta, as, children, ...muut }) {
4
5
  const Elementti = as ?? "span";
5
6
  const { isEnabled: muokkaustila } = await draftMode();
6
- return (_jsx(Elementti, { ...muut, ...(muokkaustila ? { "data-editori-kentta": kentta } : {}), children: children }));
7
+ return (_jsx(Elementti, { ...muut, ...(muokkaustila ? { "data-editori-kentta": kentta } : {}), children: rivinvaihdoin(children) }));
7
8
  }
@@ -0,0 +1,2 @@
1
+ import { type ReactNode } from "react";
2
+ export declare function rivinvaihdoin(lapset: ReactNode): ReactNode;
@@ -0,0 +1,30 @@
1
+ import { createElement, Fragment } from "react";
2
+ /* ==========================================================================
3
+ RIVINVAIHDOT JA KAPPALEVÄLIT
4
+ ==========================================================================
5
+ Tekstialueeseen kirjoitettu rivinvaihto tallentuu tekstiin merkkinä "\n",
6
+ mutta HTML näyttää sen välilyöntinä. Tämä muuttaa sen sivulla <br>:ksi:
7
+
8
+ Enter → uusi rivi
9
+ tyhjä rivi → kappaleväli (<br><br>)
10
+
11
+ <br> eikä <p>, koska kenttä on usein itse <p> eikä kappaletta voi laittaa
12
+ kappaleen sisään. <br> toimii minkä tahansa elementin sisällä ilman, että
13
+ sivuston CSS:ää tarvitsee muuttaa — ja editori lukee sen takaisin samaksi
14
+ "\n":ksi (selain/mitta.ts, tekstiRiveina).
15
+
16
+ Vain merkkijonolapsi muuttuu. Jos sivusto rakentaa kentän itse
17
+ kappaleiksi tai listaksi, sen rakenne jää koskematta.
18
+ ========================================================================== */
19
+ export function rivinvaihdoin(lapset) {
20
+ if (typeof lapset !== "string" || !/[\r\n]/.test(lapset))
21
+ return lapset;
22
+ const rivit = lapset
23
+ .replace(/\r\n?/g, "\n")
24
+ /* Kolme tyhjää riviä on sama kappaleväli kuin yksi: sivun rytmi pysyy
25
+ designin mukaisena, vaikka Enteriä painaisi monta kertaa. */
26
+ .replace(/\n{3,}/g, "\n\n")
27
+ .replace(/^\n+|\n+$/g, "")
28
+ .split("\n");
29
+ return rivit.map((rivi, sija) => createElement(Fragment, { key: sija }, sija > 0 ? createElement("br") : null, rivi));
30
+ }
@@ -6,10 +6,6 @@ export interface Tiedosto {
6
6
  /** base64 kuville, utf-8 tekstille. Oletus utf-8. */
7
7
  koodaus?: "utf-8" | "base64";
8
8
  }
9
- export interface Tekija {
10
- nimi: string;
11
- sahkoposti: string;
12
- }
13
9
  export declare class RistiriitaVirhe extends Error {
14
10
  /** Repon nykyinen versio. */
15
11
  nykyinenVersio: string;
@@ -33,7 +29,6 @@ export declare function lueTiedosto(asetukset: GithubAsetukset, polku: string, v
33
29
  export interface Tallennus {
34
30
  tiedostot: Tiedosto[];
35
31
  viesti: string;
36
- tekija: Tekija;
37
32
  /**
38
33
  * Tiedostokohtaiset versiot, joista muokkaus alkoi: polku → versio.
39
34
  *
@@ -127,13 +127,30 @@ async function kirjoitaKerran(asetukset, tallennus, kohteet, tarkistaRistiriita)
127
127
  menetelma: "POST",
128
128
  runko: { base_tree: nykyinen, tree: blobit },
129
129
  });
130
+ /*
131
+ Tekijätietoa EI aseteta tarkoituksella.
132
+
133
+ Aiemmin tekijäksi merkittiin muokkaajan oma nimi ja sähköposti. Se näytti
134
+ kauniilta repon historiassa, mutta esti julkaisun: Vercel vaatii, että
135
+ commitin sähköposti vastaa jotakin GitHub-tiliä, ja asiakkaalla ei
136
+ tyypillisesti ole GitHub-tunnusta lainkaan. Julkaisu jäi tilaan "Blocked"
137
+ eikä rakennusta edes yritetty — virhe, joka ei näy build-lokeissa, koska
138
+ niitä ei synny. Tämä tuli vastaan Storyanalyticassa 4.10.2026, kun
139
+ asiakas teki ensimmäiset omat muokkauksensa.
140
+
141
+ Kun tekijä jätetään pois, GitHub merkitsee commitin sovelluksen nimiin.
142
+ Se on oikea GitHub-identiteetti, jonka Vercel hyväksyy.
143
+
144
+ Tieto muokkaajasta ei katoa: se on commit-viestin rungossa muodossa
145
+ "Muokannut <osoite> sivuston editorilla". GitHubin listanäkymä esittää
146
+ vain otsikkorivin, mutta commit itse kertoo tekijän.
147
+ */
130
148
  const commit = await github(asetukset, `/repos/${asetukset.repo}/git/commits`, {
131
149
  menetelma: "POST",
132
150
  runko: {
133
151
  message: tallennus.viesti,
134
152
  tree: puu.sha,
135
153
  parents: [nykyinen],
136
- author: { name: tallennus.tekija.nimi, email: tallennus.tekija.sahkoposti },
137
154
  },
138
155
  });
139
156
  await github(asetukset, `/repos/${asetukset.repo}/git/refs/heads/${asetukset.haara}`, {
@@ -118,7 +118,6 @@ export function kuvienReitti(yliajot) {
118
118
  },
119
119
  ],
120
120
  viesti: `Uusi kuva: ${kansio}\n\nLadannut ${muokkaaja} sivuston editorilla.`,
121
- tekija: { nimi: muokkaaja.split("@")[0] ?? muokkaaja, sahkoposti: muokkaaja },
122
121
  });
123
122
  }
124
123
  catch (virhe) {
@@ -21,11 +21,6 @@ import { kaannosAvain } from "./kaannos.js";
21
21
  import { kuvatekstiAvain } from "./kuvateksti.js";
22
22
  import { RistiriitaVirhe } from "./github/tiedostot.js";
23
23
  import { valitseVarasto } from "./varasto.js";
24
- /** Tekijän nimi commitiin. Sähköpostista ennen @-merkkiä, siistittynä. */
25
- function tekijanNimi(sahkoposti) {
26
- const alku = sahkoposti.split("@")[0] ?? sahkoposti;
27
- return alku.replace(/[._-]+/g, " ").trim() || sahkoposti;
28
- }
29
24
  function commitinViesti(sivujenNimet, muokkaaja) {
30
25
  const mita = sivujenNimet.join(", ");
31
26
  return `Sisältömuutos: ${mita}\n\nMuokannut ${muokkaaja} sivuston editorilla.`;
@@ -146,7 +141,6 @@ export function sisallonReitit(yliajot = {}) {
146
141
  const { versio } = await varasto().tallenna({
147
142
  tiedostot,
148
143
  viesti: commitinViesti(nimet, muokkaaja),
149
- tekija: { nimi: tekijanNimi(muokkaaja), sahkoposti: muokkaaja },
150
144
  ...(Object.keys(perustuu).length > 0 ? { perustuu } : {}),
151
145
  });
152
146
  /* Uudet tiedostoversiot mukaan, jotta seuraava tallennus samasta
@@ -1,3 +1,3 @@
1
- declare const PALKIN_TYYLIT = "\n :host { all: initial; }\n .palkki {\n position: fixed; inset: auto 1rem 1rem auto; z-index: 2147483000;\n display: flex; align-items: center; gap: .75rem;\n padding: .6rem .75rem .6rem 1rem; border-radius: .5rem;\n background: #1d1f1e; color: #fff;\n font: 500 .875rem/1.4 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n box-shadow: 0 .5rem 1.5rem rgba(0,0,0,.25);\n max-width: calc(100vw - 2rem);\n }\n .tila { opacity: .75; font-weight: 400; }\n .tila[data-virhe=\"1\"] { color: #ffb4a2; opacity: 1; }\n button {\n font: inherit; padding: .45rem .8rem; border-radius: .35rem;\n border: 1px solid rgba(255,255,255,.25); background: transparent; color: inherit;\n cursor: pointer;\n }\n button.ensisijainen { background: #fff; color: #1d1f1e; border-color: #fff; }\n button:disabled { opacity: .5; cursor: default; }\n button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }\n\n /* Sivupaneeli */\n .paneeli {\n position: fixed; inset: 0 0 0 auto; z-index: 2147482999;\n width: min(23rem, 100vw); overflow-y: auto;\n padding: 1.25rem 1.25rem 6rem;\n background: #fbfaf8; color: #1d1f1e;\n border-left: 1px solid rgba(0,0,0,.12);\n font: 400 .875rem/1.5 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n box-shadow: -.5rem 0 1.5rem rgba(0,0,0,.08);\n }\n .paneeli h2 { margin: 0 0 .75rem; font-size: .8125rem; text-transform: uppercase; letter-spacing: .06em; opacity: .6; }\n .paneeli section + section { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid rgba(0,0,0,.1); }\n .kentta { margin-bottom: 1rem; }\n .kentta label { display: flex; gap: .5rem; align-items: baseline; margin-bottom: .25rem; font-weight: 600; }\n .kieli { font-weight: 400; font-size: .75rem; padding: 0 .3rem; border-radius: .2rem; background: rgba(0,0,0,.08); text-transform: uppercase; }\n .kentta input[type=text], .kentta input[type=number], .kentta input[type=date],\n .kentta input[type=datetime-local], .kentta select, .kentta textarea {\n width: 100%; padding: .45rem .55rem; border: 1px solid rgba(0,0,0,.25);\n border-radius: .25rem; background: #fff; font: inherit; color: inherit;\n }\n .kentta textarea { resize: vertical; }\n .kytkin { display: flex; gap: .5rem; align-items: center; font-weight: 400; }\n .kielivalinta { display: grid; gap: .35rem; }\n .kaannos button { color: inherit; border-color: rgba(0,0,0,.25); }\n .ohje { margin: .25rem 0 0; font-size: .8125rem; opacity: .65; }\n .laskuri { margin: .3rem 0 0; font-size: .8125rem; font-weight: 600; }\n /* Pakollinen kentt\u00E4: punainen t\u00E4hti on tuttu merkki, eik\u00E4 se vie tilaa. */\n .pakko { color: #b3261e; font-weight: 700; }\n .piilossa {\n position: absolute; width: 1px; height: 1px; overflow: hidden;\n clip-path: inset(50%); white-space: nowrap;\n }\n .linkki {\n background: none; border: 0; padding: 0; margin: 0; font: inherit;\n color: inherit; text-decoration: underline; cursor: pointer;\n }\n .virheet { padding: .75rem .9rem; border-radius: .3rem; background: #f6e8e3; color: #6d2b20; }\n .virheet h2 { margin: 0 0 .4rem; opacity: 1; color: inherit; }\n .virheet ul { margin: 0; padding-left: 1.1rem; display: grid; gap: .25rem; }\n .kuva img { display: block; width: 100%; height: auto; border-radius: .25rem; margin-bottom: .4rem; }\n .kuva input[type=text] { width: 100%; padding: .45rem .55rem; border: 1px solid rgba(0,0,0,.25); border-radius: .25rem; font: inherit; }\n /* Kuvan tekstikentt\u00E4 on oma lohkonsa: label on .kentta-tasolla flex,\n ja ilman t\u00E4t\u00E4 otsikko ja kentt\u00E4 asettuisivat vierekk\u00E4in. */\n .kentta label.alt, .kuva label.alt { display: block; font-weight: 600; margin-top: .5rem; }\n .alt input { margin-top: .25rem; width: 100%; }\n .huomio {\n margin: .4rem 0 0; padding: .5rem .6rem; border-radius: .25rem;\n background: #fdf3e7; color: #6b4a1f; font-size: .8125rem;\n }\n .huomio p { margin: 0 0 .4rem; }\n .huomio button { color: inherit; border-color: rgba(107,74,31,.35); padding: .3rem .6rem; }\n .lataus { display: inline-flex; margin: 0 0 .5rem; }\n .lataus span {\n padding: .45rem .8rem; border: 1px solid rgba(0,0,0,.25); border-radius: .35rem;\n cursor: pointer; font-weight: 600;\n }\n .lataus input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; }\n .virheviesti { margin: 0 0 .5rem; color: #8c3b2e; font-size: .8125rem; }\n .tyhja { margin: 0; opacity: .6; }\n .kohteet { list-style: none; margin: 0 0 .75rem; padding: 0; display: grid; gap: .25rem; }\n .kohteet button {\n width: 100%; text-align: left; color: inherit; border-color: rgba(0,0,0,.15);\n display: flex; justify-content: space-between; gap: .5rem; align-items: center;\n }\n .kohteet button.valittu { background: #1d1f1e; color: #fff; border-color: #1d1f1e; }\n .merkki { font-size: .75rem; opacity: .7; white-space: nowrap; }\n /* Taitos n\u00E4ytt\u00E4\u00E4 painikkeelta ja nuoli osoittaa sinne, minne sis\u00E4lt\u00F6\n aukeaa: alas kiinni ollessa, yl\u00F6s auki ollessa. */\n .ryhma { margin: 0 0 1rem; padding: .75rem .8rem; border-radius: .3rem; background: rgba(0,0,0,.035); }\n .ryhma h3 { margin: 0 0 .5rem; font-size: .8125rem; text-transform: uppercase; letter-spacing: .05em; opacity: .65; }\n .ryhma .kentta:last-child { margin-bottom: 0; }\n .lisatiedot { margin: .25rem 0 1rem; border-top: 1px solid rgba(0,0,0,.1); }\n .lisatiedot summary {\n cursor: pointer; font-weight: 600; padding: .7rem 0; list-style: none;\n display: flex; align-items: center; gap: .5rem;\n }\n .lisatiedot summary::-webkit-details-marker { display: none; }\n .lisatiedot summary::after {\n content: \"\u25BE\"; font-size: 1.1rem; line-height: 1; margin-left: auto;\n }\n .lisatiedot[open] summary::after { content: \"\u25B4\"; }\n .lisatiedot summary:hover { text-decoration: underline; }\n .lisatiedot summary:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n .kaannos { margin: 0 0 1rem; padding: .6rem .7rem; border-radius: .25rem; background: #eef1ee; }\n .kaannos p { margin: 0 0 .5rem; }\n /* Kenttien nimet omalle rivilleen: lause pysyy luettavana ja nimet erottuvat. */\n .kaannos strong { display: block; margin-top: .15rem; }\n .kaannos button { color: inherit; border-color: rgba(0,0,0,.25); }\n .napit { display: flex; gap: .5rem; margin-bottom: 1rem; }\n .napit button { color: inherit; border-color: rgba(0,0,0,.25); }\n .napit button.vaarallinen { color: #8c3b2e; border-color: rgba(140,59,46,.4); }\n";
1
+ declare const PALKIN_TYYLIT = "\n :host { all: initial; }\n .palkki {\n position: fixed; inset: auto 1rem 1rem auto; z-index: 2147483000;\n display: flex; align-items: center; gap: .75rem;\n padding: .6rem .75rem .6rem 1rem; border-radius: .5rem;\n background: #1d1f1e; color: #fff;\n font: 500 .875rem/1.4 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n box-shadow: 0 .5rem 1.5rem rgba(0,0,0,.25);\n max-width: calc(100vw - 2rem);\n }\n .tila { opacity: .75; font-weight: 400; }\n .tila[data-virhe=\"1\"] { color: #ffb4a2; opacity: 1; }\n button {\n font: inherit; padding: .45rem .8rem; border-radius: .35rem;\n border: 1px solid rgba(255,255,255,.25); background: transparent; color: inherit;\n cursor: pointer;\n }\n button.ensisijainen { background: #fff; color: #1d1f1e; border-color: #fff; }\n button:disabled { opacity: .5; cursor: default; }\n button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }\n\n /* Sivupaneeli */\n .paneeli {\n position: fixed; inset: 0 0 0 auto; z-index: 2147482999;\n width: min(23rem, 100vw); overflow-y: auto;\n padding: 1.25rem 1.25rem 6rem;\n background: #fbfaf8; color: #1d1f1e;\n border-left: 1px solid rgba(0,0,0,.12);\n font: 400 .875rem/1.5 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n box-shadow: -.5rem 0 1.5rem rgba(0,0,0,.08);\n }\n .paneeli h2 { margin: 0 0 .75rem; font-size: .8125rem; text-transform: uppercase; letter-spacing: .06em; opacity: .6; }\n .paneeli section + section { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid rgba(0,0,0,.1); }\n .kentta { margin-bottom: 1rem; }\n .kentta label { display: flex; gap: .5rem; align-items: baseline; margin-bottom: .25rem; font-weight: 600; }\n .kieli { font-weight: 400; font-size: .75rem; padding: 0 .3rem; border-radius: .2rem; background: rgba(0,0,0,.08); text-transform: uppercase; }\n .kentta input[type=text], .kentta input[type=number], .kentta input[type=date],\n .kentta input[type=datetime-local], .kentta select, .kentta textarea {\n width: 100%; padding: .45rem .55rem; border: 1px solid rgba(0,0,0,.25);\n border-radius: .25rem; background: #fff; font: inherit; color: inherit;\n }\n .kentta textarea { resize: vertical; }\n .kytkin { display: flex; gap: .5rem; align-items: center; font-weight: 400; }\n .kielivalinta { display: grid; gap: .35rem; }\n .kaannos button { color: inherit; border-color: rgba(0,0,0,.25); }\n .ohje { margin: .25rem 0 0; font-size: .8125rem; opacity: .65; }\n .laskuri { margin: .3rem 0 0; font-size: .8125rem; font-weight: 600; }\n /* Pakollinen kentt\u00E4: punainen t\u00E4hti on tuttu merkki, eik\u00E4 se vie tilaa. */\n .pakko { color: #b3261e; font-weight: 700; }\n .piilossa {\n position: absolute; width: 1px; height: 1px; overflow: hidden;\n clip-path: inset(50%); white-space: nowrap;\n }\n .linkki {\n background: none; border: 0; padding: 0; margin: 0; font: inherit;\n color: inherit; text-decoration: underline; cursor: pointer;\n }\n .virheet { padding: .75rem .9rem; border-radius: .3rem; background: #f6e8e3; color: #6d2b20; }\n .virheet h2 { margin: 0 0 .4rem; opacity: 1; color: inherit; }\n .virheet ul { margin: 0; padding-left: 1.1rem; display: grid; gap: .25rem; }\n .kuva img { display: block; width: 100%; height: auto; border-radius: .25rem; margin-bottom: .4rem; }\n .kuva input[type=text] { width: 100%; padding: .45rem .55rem; border: 1px solid rgba(0,0,0,.25); border-radius: .25rem; font: inherit; }\n /* Kuvan tekstikentt\u00E4 on oma lohkonsa: label on .kentta-tasolla flex,\n ja ilman t\u00E4t\u00E4 otsikko ja kentt\u00E4 asettuisivat vierekk\u00E4in. */\n .kentta label.alt, .kuva label.alt { display: block; font-weight: 600; margin-top: .5rem; }\n .alt input { margin-top: .25rem; width: 100%; }\n .huomio {\n margin: .4rem 0 0; padding: .5rem .6rem; border-radius: .25rem;\n background: #fdf3e7; color: #6b4a1f; font-size: .8125rem;\n }\n .huomio p { margin: 0 0 .4rem; }\n .huomio button { color: inherit; border-color: rgba(107,74,31,.35); padding: .3rem .6rem; }\n .lataus { display: inline-flex; margin: 0 0 .5rem; }\n .lataus span {\n padding: .45rem .8rem; border: 1px solid rgba(0,0,0,.25); border-radius: .35rem;\n cursor: pointer; font-weight: 600;\n }\n .lataus input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; }\n .virheviesti { margin: 0 0 .5rem; color: #8c3b2e; font-size: .8125rem; }\n .tyhja { margin: 0; opacity: .6; }\n .kohteet { list-style: none; margin: 0 0 .75rem; padding: 0; display: grid; gap: .25rem; }\n .kohteet button {\n width: 100%; text-align: left; color: inherit; border-color: rgba(0,0,0,.15);\n display: flex; justify-content: space-between; gap: .5rem; align-items: center;\n }\n .kohteet button.valittu { background: #1d1f1e; color: #fff; border-color: #1d1f1e; }\n .merkki { font-size: .75rem; opacity: .7; white-space: nowrap; }\n /* Taitos n\u00E4ytt\u00E4\u00E4 painikkeelta ja nuoli osoittaa sinne, minne sis\u00E4lt\u00F6\n aukeaa: alas kiinni ollessa, yl\u00F6s auki ollessa. */\n .ryhma { margin: 0 0 1rem; padding: .75rem .8rem; border-radius: .3rem; background: rgba(0,0,0,.035); }\n .ryhma h3 { margin: 0 0 .5rem; font-size: .8125rem; text-transform: uppercase; letter-spacing: .05em; opacity: .65; }\n .ryhma .kentta:last-child { margin-bottom: 0; }\n .lisatiedot { margin: .25rem 0 1rem; border-top: 1px solid rgba(0,0,0,.1); }\n .lisatiedot summary {\n cursor: pointer; font-weight: 600; padding: .7rem 0; list-style: none;\n display: flex; align-items: center; gap: .5rem;\n }\n .lisatiedot summary::-webkit-details-marker { display: none; }\n .lisatiedot summary::after {\n content: \"\u25BE\"; font-size: 1.1rem; line-height: 1; margin-left: auto;\n }\n .lisatiedot[open] summary::after { content: \"\u25B4\"; }\n .lisatiedot summary:hover { text-decoration: underline; }\n .lisatiedot summary:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n .kaannos { margin: 0 0 1rem; padding: .6rem .7rem; border-radius: .25rem; background: #eef1ee; }\n .kaannos p { margin: 0 0 .5rem; }\n /* Kenttien nimet omalle rivilleen: lause pysyy luettavana ja nimet erottuvat. */\n .kaannos strong { display: block; margin-top: .15rem; }\n .kaannos button { color: inherit; border-color: rgba(0,0,0,.25); }\n .napit { display: flex; gap: .5rem; margin-bottom: 1rem; }\n .napit button { color: inherit; border-color: rgba(0,0,0,.25); }\n .napit button.vaarallinen { color: #8c3b2e; border-color: rgba(140,59,46,.4); }\n\n /* Kent\u00E4n mitta: pieni lappu aktiivisen kent\u00E4n alla. Kertoo, mihin\n kentt\u00E4\u00E4n kirjoitetaan ja kuinka paljon siihen on suunniteltu teksti\u00E4.\n Sijainti asetetaan CSSOM:lla (elementti.style), jota CSP ei rajoita. */\n .mitta {\n position: fixed; top: 0; left: 0; z-index: 2147483001;\n max-width: min(24rem, calc(100vw - 1rem));\n padding: .4rem .6rem; border-radius: .35rem;\n background: #1d1f1e; color: #fff;\n font: 400 .75rem/1.45 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n box-shadow: 0 .25rem .75rem rgba(0,0,0,.25);\n pointer-events: none;\n }\n .mitta p { margin: 0; }\n .mitta p + p { margin-top: .2rem; }\n .mitta .nimi { font-weight: 600; }\n .mitta .maara { font-variant-numeric: tabular-nums; opacity: .8; }\n .mitta .ohje { opacity: .75; }\n .mitta[data-yli=\"1\"] { background: #fdf3e7; color: #6b4a1f; border: 1px solid rgba(107,74,31,.35); }\n .mitta[data-yli=\"1\"] .maara { opacity: 1; font-weight: 700; }\n";
2
2
  export declare function Muokkaustila(): import("react").JSX.Element | null;
3
3
  export { PALKIN_TYYLIT };
@@ -19,9 +19,10 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
19
19
  3. **Mitään ei menetetä vahingossa.** Tallentamattomista muutoksista
20
20
  varoitetaan lähtiessä, ja *Peru* palauttaa alkuperäiset tekstit.
21
21
  ========================================================================== */
22
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
23
23
  import { valitseKieli } from "./kieli.js";
24
24
  import { pienennaKuva } from "./kuva.js";
25
+ import { jaaKappaleiksi, kentanMitta, onYksirivinen, tekstiRiveina, yhdeksiRiviksi, } from "./mitta.js";
25
26
  import { Paneeli } from "./Paneeli.js";
26
27
  import { asetaArvo, kirjoitaKentanpolku, lueArvo, lueKentanpolku, vapaaTunnus, } from "./polku.js";
27
28
  const PALKIN_TYYLIT = `
@@ -137,15 +138,53 @@ const PALKIN_TYYLIT = `
137
138
  .napit { display: flex; gap: .5rem; margin-bottom: 1rem; }
138
139
  .napit button { color: inherit; border-color: rgba(0,0,0,.25); }
139
140
  .napit button.vaarallinen { color: #8c3b2e; border-color: rgba(140,59,46,.4); }
141
+
142
+ /* Kentän mitta: pieni lappu aktiivisen kentän alla. Kertoo, mihin
143
+ kenttään kirjoitetaan ja kuinka paljon siihen on suunniteltu tekstiä.
144
+ Sijainti asetetaan CSSOM:lla (elementti.style), jota CSP ei rajoita. */
145
+ .mitta {
146
+ position: fixed; top: 0; left: 0; z-index: 2147483001;
147
+ max-width: min(24rem, calc(100vw - 1rem));
148
+ padding: .4rem .6rem; border-radius: .35rem;
149
+ background: #1d1f1e; color: #fff;
150
+ font: 400 .75rem/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
151
+ box-shadow: 0 .25rem .75rem rgba(0,0,0,.25);
152
+ pointer-events: none;
153
+ }
154
+ .mitta p { margin: 0; }
155
+ .mitta p + p { margin-top: .2rem; }
156
+ .mitta .nimi { font-weight: 600; }
157
+ .mitta .maara { font-variant-numeric: tabular-nums; opacity: .8; }
158
+ .mitta .ohje { opacity: .75; }
159
+ .mitta[data-yli="1"] { background: #fdf3e7; color: #6b4a1f; border: 1px solid rgba(107,74,31,.35); }
160
+ .mitta[data-yli="1"] .maara { opacity: 1; font-weight: 700; }
140
161
  `;
141
162
  const KOROSTUS = `
142
163
  [data-editori-kentta] { border-radius: .15rem; }
164
+ /* Kehys tekstin omalla värillä: musta katkoviiva katosi tummilla
165
+ sivustoilla, eikä muokkaaja nähnyt, missä kentät ovat. Tekstin väri
166
+ erottuu taustasta aina, koska sivun design on tehty niin. */
143
167
  [data-editori-muokattavana="1"] {
144
- outline: 1px dashed rgba(0,0,0,.35); outline-offset: .25rem; cursor: text;
168
+ outline: 1px dashed color-mix(in srgb, currentColor 45%, transparent);
169
+ outline-offset: .25rem; cursor: text;
145
170
  }
146
171
  [data-editori-muokattavana="1"]:hover { outline-style: solid; }
147
172
  [data-editori-muokattavana="1"]:focus { outline: 2px solid currentColor; outline-offset: .25rem; }
148
173
  [data-editori-muokattavana="1"] > p:last-child { margin-bottom: 0; }
174
+ /* Tyhjä kenttä ei saa kadota: ilman tekstiä sen korkeus olisi nolla ja
175
+ katkoviiva kutistuisi viivaksi. Paikkamerkki on kentän nimi, ja se
176
+ latoutuu kentän omalla fontilla, joten se kertoo myös mittakaavan. */
177
+ [data-editori-tyhja] { min-height: 1.4em; min-width: 12ch; }
178
+ /* Kirjoittaessa Enter lisää tekstiin rivinvaihdon. Chrome ja Safari
179
+ näyttävät sen muokattavassa kentässä itsekin; tämä varmistaa saman
180
+ muissa selaimissa. Julkinen sivu näyttää rivinvaihdon <br>:nä
181
+ (komponentit/rivinvaihdot.ts). */
182
+ [data-editori-monirivinen] { white-space: pre-line; }
183
+ span[data-editori-tyhja] { display: inline-block; }
184
+ [data-editori-tyhja]::before {
185
+ content: attr(data-editori-nimi) " – kirjoita tähän";
186
+ opacity: .45; font-style: italic; pointer-events: none;
187
+ }
149
188
  body[data-editori-paneeli="1"] { padding-right: min(23rem, 100vw); }
150
189
  `;
151
190
  /** Lisää tyylit ilman inline-tyylejä, jotta CSP:tä ei tarvitse löysätä. */
@@ -159,25 +198,39 @@ function lisaaTyylit() {
159
198
  document.adoptedStyleSheets = document.adoptedStyleSheets.filter((muu) => muu !== arkki);
160
199
  };
161
200
  }
201
+ /** Tyhjä kenttä saa paikkamerkin; ks. KOROSTUS. */
202
+ function merkitseTyhja(elementti) {
203
+ elementti.toggleAttribute("data-editori-tyhja", (elementti.textContent ?? "").trim() === "");
204
+ }
205
+ /** Kursori kentän tekstin loppuun, kun kenttään siirrytään Enterillä. */
206
+ function kursoriLoppuun(elementti) {
207
+ const valinta = window.getSelection();
208
+ if (!valinta)
209
+ return;
210
+ const alue = document.createRange();
211
+ alue.selectNodeContents(elementti);
212
+ alue.collapse(false);
213
+ valinta.removeAllRanges();
214
+ valinta.addRange(alue);
215
+ }
216
+ /** Muokattava kenttä, jonka sisällä tapahtuma on. */
217
+ function muokattavaKentta(kohde) {
218
+ return kohde instanceof HTMLElement
219
+ ? kohde.closest('[data-editori-muokattavana="1"]')
220
+ : null;
221
+ }
162
222
  /**
163
223
  * Kentän teksti elementistä.
164
224
  *
165
- * Kappalelistalla luetaan `innerText`, joka säilyttää kappalejaon
166
- * rivinvaihtoina. Tavallisella tekstillä `textContent` riittää eikä se ole
167
- * riippuvainen siitä, miten selain sattuu taittamaan sisällön.
225
+ * Monirivisellä kentällä (lista, tekstialue) kappalejako luetaan sivun
226
+ * rakenteesta, ks. tekstiRiveina. Yksirivisellä `textContent` riittää.
227
+ * Kumpikaan ei riipu siitä, miten sivu on muotoiltu tai mitä on näkyvissä.
168
228
  */
169
- function lueTeksti(elementti, lista) {
170
- return (lista ? elementti.innerText : elementti.textContent) ?? "";
229
+ function lueTeksti(elementti, monirivinen) {
230
+ return monirivinen ? tekstiRiveina(elementti) : (elementti.textContent ?? "");
171
231
  }
172
- /**
173
- * Sivulla kirjoitettu lista takaisin listaksi.
174
- *
175
- * Oletuksena yksi rivi on yksi kohta. Jos sivusto näyttää listan yhtenä
176
- * tekstinä (esim. "Lounas, Materiaalit"), erotin tulee sisältömallista.
177
- */
178
- function jaaKappaleiksi(teksti, erotin) {
179
- const osat = erotin ? teksti.split(erotin.trim()) : teksti.split(/\n+/);
180
- return osat.map((osa) => osa.trim()).filter((osa) => osa !== "");
232
+ function onMonirivinen(maaritys) {
233
+ return maaritys?.tyyppi === "tekstilista" || maaritys?.tyyppi === "tekstialue";
181
234
  }
182
235
  /** Sivulla olevat muokattavat kohdat merkintöineen. */
183
236
  function merkinnat() {
@@ -203,6 +256,12 @@ export function Muokkaustila() {
203
256
  const [sisalto, setSisalto] = useState(null);
204
257
  const [valittu, setValittu] = useState(undefined);
205
258
  const alkuperaiset = useRef(new Map());
259
+ /** Peru palauttaa kentän rakenteen (kappaleet), ei pelkkää tekstiä. */
260
+ const alkuperainenRakenne = useRef(new Map());
261
+ /** Kenttä, jossa kursori on, ja hetken näkyvä vihje sen yhteydessä. */
262
+ const [aktiivinen, setAktiivinen] = useState(null);
263
+ /** Kasvaa jokaisella näppäilyllä, jotta mitta päivittyy kirjoittaessa. */
264
+ const [, setNappailyt] = useState(0);
206
265
  useEffect(() => {
207
266
  let voimassa = true;
208
267
  fetch("/api/editori/sisalto", { headers: { Accept: "application/json" } })
@@ -264,29 +323,126 @@ export function Muokkaustila() {
264
323
  else
265
324
  document.body.removeAttribute("data-editori-paneeli");
266
325
  }, [muokataan, tallentaa]);
267
- const onLista = useCallback((polku) => sisalto !== null && kentanMaaritys(sisalto, polku)?.tyyppi === "tekstilista", [sisalto]);
326
+ const onLista = useCallback((polku) => sisalto !== null && onMonirivinen(kentanMaaritys(sisalto, polku)), [sisalto]);
268
327
  const paalle = useCallback(() => {
269
328
  alkuperaiset.current = new Map();
329
+ alkuperainenRakenne.current = new Map();
270
330
  for (const { elementti, polku } of merkinnat()) {
271
331
  alkuperaiset.current.set(elementti, lueTeksti(elementti, onLista(polku)));
332
+ alkuperainenRakenne.current.set(elementti, elementti.innerHTML);
272
333
  elementti.setAttribute("contenteditable", "plaintext-only");
273
334
  elementti.setAttribute("data-editori-muokattavana", "1");
274
335
  elementti.setAttribute("spellcheck", "true");
336
+ const maaritys = sisalto ? kentanMaaritys(sisalto, polku) : undefined;
337
+ elementti.setAttribute("data-editori-nimi", maaritys?.nimi ?? polku.alikentta ?? polku.kentta);
338
+ if (onYksirivinen(maaritys))
339
+ elementti.setAttribute("data-editori-rivi", "1");
340
+ if (maaritys?.tyyppi === "tekstialue")
341
+ elementti.setAttribute("data-editori-monirivinen", "");
342
+ merkitseTyhja(elementti);
275
343
  }
276
344
  setTila("muokkaa");
277
345
  setViesti("");
278
346
  setVirheet([]);
279
- }, [onLista]);
347
+ }, [onLista, sisalto]);
280
348
  const pois = useCallback((palautaTekstit) => {
281
349
  for (const { elementti } of merkinnat()) {
282
- if (palautaTekstit) {
283
- elementti.textContent = alkuperaiset.current.get(elementti) ?? elementti.textContent;
284
- }
350
+ const rakenne = alkuperainenRakenne.current.get(elementti);
351
+ if (palautaTekstit && rakenne !== undefined)
352
+ elementti.innerHTML = rakenne;
285
353
  elementti.removeAttribute("contenteditable");
286
354
  elementti.removeAttribute("data-editori-muokattavana");
287
355
  elementti.removeAttribute("spellcheck");
356
+ elementti.removeAttribute("data-editori-nimi");
357
+ elementti.removeAttribute("data-editori-rivi");
358
+ elementti.removeAttribute("data-editori-monirivinen");
359
+ elementti.removeAttribute("data-editori-tyhja");
288
360
  }
361
+ setAktiivinen(null);
289
362
  }, []);
363
+ /*
364
+ Kirjoittaminen sivulla. Kolme asiaa, joita selain ei tee itse:
365
+
366
+ 1. Enter yksirivisessä kentässä (otsikko, painike) siirtää seuraavaan
367
+ kenttään. Ennen se lisäsi rivinvaihdon, jonka tallennus hukkasi
368
+ hiljaa, ja lauseet sulautuivat yhteen ("kysymys?Seuraava lause").
369
+ 2. Monirivinen teksti liitetään yksiriviseen kenttään yhtenä rivinä.
370
+ 3. Tyhjä kenttä saa paikkamerkin heti, kun sen viimeinen merkki
371
+ poistetaan — ei vasta seuraavalla avauksella.
372
+ */
373
+ useEffect(() => {
374
+ if (!muokataan)
375
+ return;
376
+ const kohdistus = (tapahtuma) => {
377
+ const kentta = muokattavaKentta(tapahtuma.target);
378
+ if (kentta)
379
+ setAktiivinen((nyt) => (nyt?.elementti === kentta ? nyt : { elementti: kentta }));
380
+ };
381
+ const poistuminen = (tapahtuma) => {
382
+ if (!muokattavaKentta(tapahtuma.relatedTarget))
383
+ setAktiivinen(null);
384
+ };
385
+ const kirjoitus = (tapahtuma) => {
386
+ const kentta = muokattavaKentta(tapahtuma.target);
387
+ if (!kentta)
388
+ return;
389
+ merkitseTyhja(kentta);
390
+ setNappailyt((n) => n + 1);
391
+ };
392
+ const nappain = (tapahtuma) => {
393
+ if (tapahtuma.key !== "Enter" || tapahtuma.isComposing)
394
+ return;
395
+ const kentta = muokattavaKentta(tapahtuma.target);
396
+ if (!kentta || kentta.getAttribute("data-editori-rivi") !== "1")
397
+ return;
398
+ tapahtuma.preventDefault();
399
+ const nimi = kentta.getAttribute("data-editori-nimi") ?? "Kenttä";
400
+ const kaikki = merkinnat()
401
+ .map(({ elementti }) => elementti)
402
+ .filter((elementti) => elementti.getAttribute("data-editori-muokattavana") === "1");
403
+ const seuraava = kaikki[kaikki.indexOf(kentta) + 1];
404
+ if (!seuraava) {
405
+ setAktiivinen({ elementti: kentta, vihje: `${nimi} on yksi rivi, eikä siihen voi lisätä rivinvaihtoa.` });
406
+ return;
407
+ }
408
+ seuraava.focus();
409
+ kursoriLoppuun(seuraava);
410
+ setAktiivinen({
411
+ elementti: seuraava,
412
+ vihje: `${nimi} on yksi rivi, joten jatkat tästä kentästä.`,
413
+ });
414
+ };
415
+ const liitto = (tapahtuma) => {
416
+ const kentta = muokattavaKentta(tapahtuma.target);
417
+ if (!kentta || kentta.getAttribute("data-editori-rivi") !== "1")
418
+ return;
419
+ const teksti = tapahtuma.clipboardData?.getData("text/plain") ?? "";
420
+ if (!/[\r\n]/.test(teksti))
421
+ return;
422
+ tapahtuma.preventDefault();
423
+ /* execCommand säilyttää kumoamisen (Cmd+Z), toisin kuin suora DOM-muutos. */
424
+ document.execCommand("insertText", false, yhdeksiRiviksi(teksti));
425
+ };
426
+ document.addEventListener("focusin", kohdistus);
427
+ document.addEventListener("focusout", poistuminen);
428
+ document.addEventListener("input", kirjoitus);
429
+ document.addEventListener("keydown", nappain, true);
430
+ document.addEventListener("paste", liitto, true);
431
+ return () => {
432
+ document.removeEventListener("focusin", kohdistus);
433
+ document.removeEventListener("focusout", poistuminen);
434
+ document.removeEventListener("input", kirjoitus);
435
+ document.removeEventListener("keydown", nappain, true);
436
+ document.removeEventListener("paste", liitto, true);
437
+ };
438
+ }, [muokataan]);
439
+ /* Vihje on kertaluonteinen: se katoaa hetken päästä, mitta jää. */
440
+ useEffect(() => {
441
+ if (!aktiivinen?.vihje)
442
+ return;
443
+ const ajastin = window.setTimeout(() => setAktiivinen((nyt) => (nyt?.vihje ? { elementti: nyt.elementti } : nyt)), 5000);
444
+ return () => window.clearTimeout(ajastin);
445
+ }, [aktiivinen]);
290
446
  function sivullaMuuttunut() {
291
447
  return merkinnat().some(({ elementti, polku }) => lueTeksti(elementti, onLista(polku)) !== alkuperaiset.current.get(elementti));
292
448
  }
@@ -532,7 +688,7 @@ export function Muokkaustila() {
532
688
  for (const { elementti, polku } of merkinnat()) {
533
689
  const maaritys = kentanMaaritys(sisalto, polku);
534
690
  const lista = maaritys?.tyyppi === "tekstilista";
535
- const teksti = lueTeksti(elementti, lista);
691
+ const teksti = lueTeksti(elementti, onMonirivinen(maaritys));
536
692
  if (teksti === alkuperaiset.current.get(elementti))
537
693
  continue;
538
694
  let arvo = teksti;
@@ -634,7 +790,14 @@ export function Muokkaustila() {
634
790
  muutettu.current = false;
635
791
  pois(false);
636
792
  setTila("tallennettu");
637
- setViesti("Tallennettu. Sivusto rakennetaan uudelleen noin minuutissa.");
793
+ /* Pituusvaroitukset sanotaan ääneen. Ennen ne palasivat palvelimelta
794
+ eikä niitä näytetty missään, joten kaksinkertainen otsikko meni
795
+ läpi kenenkään huomaamatta. Vain tässä tallennuksessa muutetut
796
+ kentät: vanhat ylitykset toistuisivat joka kerta meluna. */
797
+ const pitkat = pituusvaroitukset(sisalto, muutetut, tulos.varoitukset);
798
+ setViesti(pitkat.length > 0
799
+ ? `Tallennettu. Suunniteltua pidempi: ${pitkat.join(", ")}. Tarkista, miltä se näyttää puhelimella.`
800
+ : "Tallennettu. Sivusto rakennetaan uudelleen noin minuutissa.");
638
801
  }
639
802
  catch {
640
803
  setTila("muokkaa");
@@ -655,6 +818,12 @@ export function Muokkaustila() {
655
818
  if (tila === "piilossa")
656
819
  return null;
657
820
  const malli = sisalto && sivunAvain ? sisalto.malli[sivunAvain] : undefined;
821
+ const aktiivisenMaaritys = (() => {
822
+ if (!aktiivinen || !sisalto)
823
+ return undefined;
824
+ const polku = lueKentanpolku(aktiivinen.elementti.getAttribute("data-editori-kentta") ?? "");
825
+ return polku ? kentanMaaritys(sisalto, polku) : undefined;
826
+ })();
658
827
  const sivullaNakyvat = new Set(merkinnat().map(({ elementti }) => elementti.getAttribute("data-editori-kentta") ?? ""));
659
828
  return (_jsxs(_Fragment, { children: [(muokataan || tallentaa) && sisalto && sivunAvain && malli ? (_jsx(Paneeli, { sivu: sivunAvain, malli: malli, data: sisalto.sivut[sivunAvain] ?? {}, kieli: sivunKieli, kielet: sisalto.kielet, sivullaNakyvat: sivullaNakyvat, valittu: valittu, virheet: virheet, muuta: muuta, lataaKuva: lataaKuva, kaanna: kaanna, kaannosKaytossa: sisalto.ominaisuudet?.kaannos ?? false, kaannettavia: {
660
829
  sivu: kaannettavat
@@ -663,7 +832,79 @@ export function Muokkaustila() {
663
832
  kohde: kaannettavat
664
833
  .filter(({ polku }) => polku.tunnus !== undefined)
665
834
  .map(({ nimi }) => nimi),
666
- }, valitse: setValittu, lisaaKohde: lisaaKohde, poistaKohde: poistaKohde })) : null, _jsx("div", { className: "palkki", role: "region", "aria-label": "Sis\u00E4ll\u00F6n muokkaus", children: tila === "lataa" ? (_jsx("span", { className: "tila", children: "Editori avautuu\u2026" })) : muokataan || tallentaa ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "tila", role: "status", "data-virhe": viesti ? "1" : "0", children: tallentaa ? "Tallennetaan…" : (viesti ?? "") !== "" ? viesti : "Muokkaat sivua" }), _jsx("button", { type: "button", onClick: peru, disabled: tallentaa, children: "Peru" }), _jsx("button", { type: "button", className: "ensisijainen", onClick: tallenna, disabled: tallentaa, children: "Tallenna" })] })) : (_jsxs(_Fragment, { children: [viesti ? (_jsx("span", { className: "tila", role: "status", "data-virhe": tila === "virhe" ? "1" : "0", children: viesti })) : null, tila === "tallennettu" ? (_jsx("button", { type: "button", onClick: () => location.reload(), children: "P\u00E4ivit\u00E4 sivu" })) : null, sisalto && malli ? (_jsx("button", { type: "button", className: "ensisijainen", onClick: paalle, children: "Muokkaa" })) : sisalto ? (_jsx("span", { className: "tila", children: "T\u00E4ll\u00E4 sivulla ei ole muokattavaa sis\u00E4lt\u00F6\u00E4." })) : (_jsx("button", { type: "button", onClick: () => location.reload(), children: "Lataa uudelleen" }))] })) })] }));
835
+ }, valitse: setValittu, lisaaKohde: lisaaKohde, poistaKohde: poistaKohde })) : null, muokataan && aktiivinen ? (_jsx(KentanMittaLappu, { elementti: aktiivinen.elementti, nimi: aktiivinen.elementti.getAttribute("data-editori-nimi") ?? "", mitta: kentanMitta(aktiivisenMaaritys, lueTeksti(aktiivinen.elementti, onMonirivinen(aktiivisenMaaritys))), vihje: aktiivinen.vihje, ohje: aktiivisenMaaritys?.tyyppi === "tekstialue"
836
+ ? "Enter tekee uuden rivin, tyhjä rivi kappalevälin."
837
+ : aktiivisenMaaritys?.tyyppi === "tekstilista"
838
+ ? "Jokainen rivi on oma kohtansa."
839
+ : undefined })) : null, _jsx("div", { className: "palkki", role: "region", "aria-label": "Sis\u00E4ll\u00F6n muokkaus", children: tila === "lataa" ? (_jsx("span", { className: "tila", children: "Editori avautuu\u2026" })) : muokataan || tallentaa ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "tila", role: "status", "data-virhe": viesti ? "1" : "0", children: tallentaa ? "Tallennetaan…" : (viesti ?? "") !== "" ? viesti : "Muokkaat sivua" }), _jsx("button", { type: "button", onClick: peru, disabled: tallentaa, children: "Peru" }), _jsx("button", { type: "button", className: "ensisijainen", onClick: tallenna, disabled: tallentaa, children: "Tallenna" })] })) : (_jsxs(_Fragment, { children: [viesti ? (_jsx("span", { className: "tila", role: "status", "data-virhe": tila === "virhe" ? "1" : "0", children: viesti })) : null, tila === "tallennettu" ? (_jsx("button", { type: "button", onClick: () => location.reload(), children: "P\u00E4ivit\u00E4 sivu" })) : null, sisalto && malli ? (_jsx("button", { type: "button", className: "ensisijainen", onClick: paalle, children: "Muokkaa" })) : sisalto ? (_jsx("span", { className: "tila", children: "T\u00E4ll\u00E4 sivulla ei ole muokattavaa sis\u00E4lt\u00F6\u00E4." })) : (_jsx("button", { type: "button", onClick: () => location.reload(), children: "Lataa uudelleen" }))] })) })] }));
840
+ }
841
+ /**
842
+ * Lappu aktiivisen kentän alla: nimi, täyttöaste ja hetkellinen vihje.
843
+ *
844
+ * Lappu sijoitetaan kentän alle, tai yläpuolelle jos alla ei ole tilaa.
845
+ * Jos kenttä on koko ruudun kokoinen, lappu jää ruudun alareunaan palkin
846
+ * yläpuolelle, jotta se on aina näkyvissä kirjoittaessa.
847
+ */
848
+ function KentanMittaLappu({ elementti, nimi, mitta, vihje, ohje, }) {
849
+ const lappu = useRef(null);
850
+ useLayoutEffect(() => {
851
+ const sijoita = () => {
852
+ const oma = lappu.current;
853
+ if (!oma)
854
+ return;
855
+ const alue = elementti.getBoundingClientRect();
856
+ const vali = 8;
857
+ /* Palkki vie ruudun alareunasta noin 4,5 rem. */
858
+ const alin = window.innerHeight - oma.offsetHeight - 72;
859
+ let yla = alue.bottom + vali;
860
+ if (yla > alin)
861
+ yla = alue.top - oma.offsetHeight - vali;
862
+ if (yla < vali)
863
+ yla = alin;
864
+ const vasen = Math.min(Math.max(vali, alue.left), window.innerWidth - oma.offsetWidth - vali);
865
+ oma.style.transform = `translate(${Math.round(vasen)}px, ${Math.round(yla)}px)`;
866
+ };
867
+ sijoita();
868
+ let kehys = 0;
869
+ const ajoita = () => {
870
+ cancelAnimationFrame(kehys);
871
+ kehys = requestAnimationFrame(sijoita);
872
+ };
873
+ window.addEventListener("scroll", ajoita, true);
874
+ window.addEventListener("resize", ajoita);
875
+ return () => {
876
+ cancelAnimationFrame(kehys);
877
+ window.removeEventListener("scroll", ajoita, true);
878
+ window.removeEventListener("resize", ajoita);
879
+ };
880
+ });
881
+ const yli = mitta !== null && mitta.nyt > mitta.raja;
882
+ return (_jsxs("div", { ref: lappu, className: "mitta", "data-yli": yli ? "1" : "0", children: [_jsxs("p", { children: [_jsx("span", { className: "nimi", children: nimi }), mitta ? (_jsxs("span", { className: "maara", children: [" · ", mitta.nyt, " / ", mitta.raja, " ", mitta.yksikko] })) : null] }), yli ? (_jsx("p", { children: mitta.yksikko === "merkkiä"
883
+ ? "Pidempi kuin tähän kohtaan on suunniteltu. Tarkista, miltä se näyttää puhelimella."
884
+ : `Tähän mahtuu enintään ${mitta.raja} ${mitta.yksikko}. Ylimääräiset estävät tallennuksen.` })) : null, ohje ? _jsx("p", { className: "ohje", children: ohje }) : null, vihje ? _jsx("p", { role: "status", children: vihje }) : null] }));
885
+ }
886
+ /** Tässä tallennuksessa muutettujen, suunniteltua pidempien kenttien nimet. */
887
+ function pituusvaroitukset(sisalto, muutetut, varoitukset) {
888
+ if (!Array.isArray(varoitukset))
889
+ return [];
890
+ const nimet = new Set();
891
+ for (const varoitus of varoitukset) {
892
+ if (!varoitus || typeof varoitus !== "object" || varoitus.syy !== "pituus")
893
+ continue;
894
+ const kohde = typeof varoitus.kohde === "string" ? varoitus.kohde : "";
895
+ const polku = lueKentanpolku(kohde);
896
+ if (!polku)
897
+ continue;
898
+ const uusi = muutetut[polku.sivu];
899
+ if (!uusi)
900
+ continue;
901
+ const tunnus = tunnusKentta(sisalto, polku);
902
+ const ennen = lueArvo(sisalto.sivut[polku.sivu] ?? {}, polku, tunnus);
903
+ if (JSON.stringify(ennen) === JSON.stringify(lueArvo(uusi, polku, tunnus)))
904
+ continue;
905
+ nimet.add(kentanMaaritys(sisalto, polku)?.nimi ?? kohde);
906
+ }
907
+ return [...nimet];
667
908
  }
668
909
  /** Mitä yhtä kohdetta tässä listassa kutsutaan, esimerkiksi "kurssi". */
669
910
  function kohdelistanYksikko(sisalto, sivu, kentta) {
@@ -0,0 +1,46 @@
1
+ import type { Kentta } from "../maarittely.ts";
2
+ /**
3
+ * Yhden rivin kenttä: otsikot, painikkeet, luvut. Enter ei tee näihin
4
+ * rivinvaihtoa, koska palvelin hylkäisi sen ja selain hukkaisi sen
5
+ * hiljaa ("kysymys?Seuraava lause").
6
+ */
7
+ export declare function onYksirivinen(maaritys: Kentta | undefined): boolean;
8
+ /**
9
+ * Sivulla kirjoitettu lista takaisin listaksi.
10
+ *
11
+ * Oletuksena yksi rivi on yksi kohta. Jos sivusto näyttää listan yhtenä
12
+ * tekstinä (esim. "Lounas, Materiaalit"), erotin tulee sisältömallista.
13
+ */
14
+ export declare function jaaKappaleiksi(teksti: string, erotin?: string): string[];
15
+ export interface Mitta {
16
+ nyt: number;
17
+ raja: number;
18
+ yksikko: "merkkiä" | "riviä" | "kohtaa";
19
+ }
20
+ /** Kentän täyttöaste, jos kentälle on suunniteltu raja; muuten null. */
21
+ export declare function kentanMitta(maaritys: Kentta | undefined, teksti: string): Mitta | null;
22
+ /**
23
+ * Liitetty monirivinen teksti yhdeksi riviksi: rivinvaihto → välilyönti.
24
+ *
25
+ * Alun rivinvaihto jää välilyönniksi, koska teksti liitetään usein
26
+ * edellisen lauseen perään. Lopun rivinvaihto on kopioinnin sivutuote
27
+ * ja poistetaan.
28
+ */
29
+ export declare function yhdeksiRiviksi(teksti: string): string;
30
+ /** Se osa DOM-solmusta, jota rivien lukeminen tarvitsee (testattavissa ilman selainta). */
31
+ export interface Solmu {
32
+ nodeType: number;
33
+ nodeName: string;
34
+ textContent: string | null;
35
+ childNodes: ArrayLike<Solmu>;
36
+ }
37
+ /**
38
+ * Monirivisen kentän teksti sivun rakenteesta: kappale, listan kohta ja
39
+ * <br> ovat rivinvaihtoja.
40
+ *
41
+ * Ei `innerText`: se lukee tekstin sellaisena kuin se näkyy ruudulla.
42
+ * Piilossa olevasta kohdasta (esim. vierityksen mukaan vaihtuva osio) se
43
+ * palauttaa tyhjää ja versaaliksi muotoillusta ("text-transform") isoja
44
+ * kirjaimia — ja tallennus kirjoitti ne tiedostoon muutoksina.
45
+ */
46
+ export declare function tekstiRiveina(juuri: Solmu): string;
@@ -0,0 +1,94 @@
1
+ import { merkkimaara } from "../tarkistus.js";
2
+ /* ==========================================================================
3
+ KENTÄN MITTA
4
+ ==========================================================================
5
+ Muokkaaja kirjoittaa suoraan sivulle, joten hän ei näe lomakkeen
6
+ reunoja. Nämä apurit kertovat, millainen kenttä on: mahtuuko siihen
7
+ yksi rivi vai kappaleita, ja kuinka paljon tekstiä siihen on suunniteltu.
8
+
9
+ Raja on ohje, ei lukko — sama periaate kuin palvelimen tarkistuksessa.
10
+ Mittaus tehdään samalla merkkimäärällä kuin siellä, jotta sivulla näkyvä
11
+ luku ja tallennuksen varoitus eivät koskaan ole eri mieltä.
12
+ ========================================================================== */
13
+ /**
14
+ * Yhden rivin kenttä: otsikot, painikkeet, luvut. Enter ei tee näihin
15
+ * rivinvaihtoa, koska palvelin hylkäisi sen ja selain hukkaisi sen
16
+ * hiljaa ("kysymys?Seuraava lause").
17
+ */
18
+ export function onYksirivinen(maaritys) {
19
+ return maaritys?.tyyppi === "teksti" || maaritys?.tyyppi === "luku";
20
+ }
21
+ /**
22
+ * Sivulla kirjoitettu lista takaisin listaksi.
23
+ *
24
+ * Oletuksena yksi rivi on yksi kohta. Jos sivusto näyttää listan yhtenä
25
+ * tekstinä (esim. "Lounas, Materiaalit"), erotin tulee sisältömallista.
26
+ */
27
+ export function jaaKappaleiksi(teksti, erotin) {
28
+ const osat = erotin ? teksti.split(erotin.trim()) : teksti.split(/\n+/);
29
+ return osat.map((osa) => osa.trim()).filter((osa) => osa !== "");
30
+ }
31
+ /** Kentän täyttöaste, jos kentälle on suunniteltu raja; muuten null. */
32
+ export function kentanMitta(maaritys, teksti) {
33
+ if (!maaritys)
34
+ return null;
35
+ if ((maaritys.tyyppi === "teksti" || maaritys.tyyppi === "tekstialue") &&
36
+ maaritys.suositusPituus !== undefined) {
37
+ return { nyt: merkkimaara(teksti), raja: maaritys.suositusPituus, yksikko: "merkkiä" };
38
+ }
39
+ if (maaritys.tyyppi === "tekstilista" && maaritys.enintaan !== undefined) {
40
+ return {
41
+ nyt: jaaKappaleiksi(teksti, maaritys.erotin).length,
42
+ raja: maaritys.enintaan,
43
+ yksikko: maaritys.erotin ? "kohtaa" : "riviä",
44
+ };
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * Liitetty monirivinen teksti yhdeksi riviksi: rivinvaihto → välilyönti.
50
+ *
51
+ * Alun rivinvaihto jää välilyönniksi, koska teksti liitetään usein
52
+ * edellisen lauseen perään. Lopun rivinvaihto on kopioinnin sivutuote
53
+ * ja poistetaan.
54
+ */
55
+ export function yhdeksiRiviksi(teksti) {
56
+ return teksti.replace(/\s+$/, "").replace(/\s*[\r\n]+\s*/g, " ");
57
+ }
58
+ const LOHKO = /^(P|DIV|LI|H[1-6]|BLOCKQUOTE|PRE)$/;
59
+ /**
60
+ * Monirivisen kentän teksti sivun rakenteesta: kappale, listan kohta ja
61
+ * <br> ovat rivinvaihtoja.
62
+ *
63
+ * Ei `innerText`: se lukee tekstin sellaisena kuin se näkyy ruudulla.
64
+ * Piilossa olevasta kohdasta (esim. vierityksen mukaan vaihtuva osio) se
65
+ * palauttaa tyhjää ja versaaliksi muotoillusta ("text-transform") isoja
66
+ * kirjaimia — ja tallennus kirjoitti ne tiedostoon muutoksina.
67
+ */
68
+ export function tekstiRiveina(juuri) {
69
+ let tulos = "";
70
+ const rivinvaihto = () => {
71
+ if (tulos !== "" && !tulos.endsWith("\n"))
72
+ tulos += "\n";
73
+ };
74
+ const kay = (solmu) => {
75
+ if (solmu.nodeType === 3) {
76
+ tulos += solmu.textContent ?? "";
77
+ return;
78
+ }
79
+ if (solmu.nodeType !== 1)
80
+ return;
81
+ if (solmu.nodeName === "BR") {
82
+ tulos += "\n";
83
+ return;
84
+ }
85
+ const lohko = LOHKO.test(solmu.nodeName);
86
+ if (lohko)
87
+ rivinvaihto();
88
+ Array.from(solmu.childNodes).forEach(kay);
89
+ if (lohko)
90
+ rivinvaihto();
91
+ };
92
+ Array.from(juuri.childNodes).forEach(kay);
93
+ return tulos.replace(/\n+$/, "");
94
+ }
@@ -11,8 +11,12 @@ export interface Huomio {
11
11
  * Ero on tarpeen, koska piilotetussa kohteessa tyhjä kenttä on varoitus
12
12
  * eikä virhe: keskeneräisen luonnoksen pitää voida tallentaa. Väärä muoto
13
13
  * on virhe silloinkin — sitä ei kirjoiteta tiedostoon missään tilanteessa.
14
+ *
15
+ * "pituus" = teksti on suunniteltua pidempi. Editori nostaa nämä esiin
16
+ * tallennuksen jälkeen; muut varoitukset (esim. puuttuva käännös) eivät
17
+ * koske juuri kirjoitettua tekstiä.
14
18
  */
15
- syy?: "puuttuu";
19
+ syy?: "puuttuu" | "pituus";
16
20
  }
17
21
  export interface Tarkistustulos {
18
22
  virheet: Huomio[];
package/dist/tarkistus.js CHANGED
@@ -39,7 +39,10 @@ function tarkistaTeksti(teksti, maaritys, kohde, kieli, tulos) {
39
39
  if (suositus !== undefined) {
40
40
  const yli = merkkimaara(teksti) - suositus;
41
41
  if (yli > 0) {
42
- tulos.varoitukset.push(huomio(`Teksti on ${yli} merkkiä pidempi kuin suunniteltu. Tarkista, miltä se näyttää puhelimella.`));
42
+ tulos.varoitukset.push({
43
+ ...huomio(`Teksti on ${yli} merkkiä pidempi kuin suunniteltu. Tarkista, miltä se näyttää puhelimella.`),
44
+ syy: "pituus",
45
+ });
43
46
  }
44
47
  }
45
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studiotitta/studio-editori",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Studio Titan sisältöeditori asiakassivustoille: tekstit ja kuvat muokataan suoraan sivulla",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",