From 99702eb36eab9f93bfaa29e0d923842bfeed5ea3 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 18 Aug 2026 01:07:24 -0300 Subject: [PATCH 01/21] iris text --- static/extensions/Gen1x/iris-text.js | 3219 ++++++++++++++++++++++++++ 1 file changed, 3219 insertions(+) create mode 100644 static/extensions/Gen1x/iris-text.js diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js new file mode 100644 index 000000000..95dc61092 --- /dev/null +++ b/static/extensions/Gen1x/iris-text.js @@ -0,0 +1,3219 @@ +/* +Iris Text by Gen1x - 2026 + +Code inspiration: +- Skins (TurboWarp version) +- Animated Text (PenguinMod version) +- Lambda by jwklong + +Enjoy!! :D +*/ + +(function(Scratch) { + 'use strict'; + + if (!Scratch.extensions.unsandboxed) { + throw new Error('Iris Text must run unsandboxed'); + } + + const vm = Scratch.vm; + const runtime = vm.runtime; + + const STATE_KEY = 'irisText'; + const DEFAULT_TEXT = 'Hello world!'; + + const blockIconURI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRAD/AP8A/6C9p5MAAAAHdElNRQfqCAwUATlcnNTYAAAXWElEQVR42tWbeZBd1X3nP+fc/d33ul9v6tYugTYkIpAsEAbMADY4rkkFFw4zIYPjZVI2YyfUxKmamsnsnsQwA3YywDhVTsoeZ/EyKTvBnomNGAzGIIODoBEIEGq1ltbS3a+7X/db73rO/HHfk7pb3eoWECfzq3ol9X33vnu+3/M7v/M7v/M9gnfJbn7s5YUudwGbgV3A1cBWYC3QA/iA1bovBurAJDACHAYGgZeBI0B5/g8/fceud6Xd4p08fOv3DqC0XAj0NcBtwA3AptY189xLxYWv1VrPv5S0gA8BzwFPAH87nwwpFD/65ff8/AlYoMc3AXe2PjsBD0BKiZQSwzCQhoGUEiFE9mmDbxGglUIpRZqmpEqh0nQ2MU3gIPDd1mdo9svfrkdcMgELAL8c+CRwN7CxDdq0LCzLyoC3QF+Kaa1RaUqSpsRxTJokKKXaXx8Dvgl8FTj6Toi4pFbNA18Efh34TbJxjmma2I6DZVlIKS/lp5e0NE2Jo4goikjTtH35CPAo8KfA9NshYVkE3PLYS+i5t+4F/hNwOyBN08RxXSzLuuSevlRTShFFEVEYtolQwL5We15o3ye14kcfXjo2LNnaeb1uAZ8A/gOwWkqJ67rYjvN3DnwhIsIgIAzDdpw4DXwe+BrZrAIs7Q0XbfU88J3Avydzece2bVzPwzCMnyvw+RbHMUGzSZIkACHZkPgvwMxySFiUgHng+4AvAfcIIXBdF8d1f+69vpgppWg2m0Rh2L7058DngNJSJCyIYN6Y7wP+B3CXlBLP87Ad5x01WC9y/Z3QqbUmCALCIGgPib8EPtsmQaB56o7dFzxnLvhj55vSSdbzd0kpyfk+lmVxqaYBCRhSYhoS0zAwpDjnQUppUqVIUkWiFKlS6EskRAiB53kIIQiaTbTWd5ENid8EZvQiv3bB1VmubwH3A7/zdsC3QdumSc6xyTkWjmViSokU4oLho7Um1ZokTQmihHoY0Yxi4vNT3rItCAKajUb7zy8C/4ZWYJw/FOa0Yt64/xTwsBDCyfk+tm1fUm/kbIvOnIfv2JiGRLC46y/UIA1ESUo1CKk0mgRxckkkNBsNgiCAzAvuA77S/m42CeYiz+8lm+oc13UvCbxrmXTnfQqegyHEOdDLAT//Pts06Mnn6PRcZhpNyvXmsj3C9bx2zuC0sLzCrDxhPuGze79Ilmb+om3b5Hx/WdFeCEHR9+jJ57ANY9mAL8WCOKFUqVELwmXdr9KUWq3WTph+SJauT8N5L5CQrepm2a8Dt0spcVtBZSkzpWSgs0B/ZwFrKfB69kfM+1z8PZ5lsqqrk5788jpFGsZsDLe3sDEbswnMXtJeThY1peu6y0pyTEMyUOyg4C4wNWoAgVbZRyUaFStUqkijGJ2m6Fa4FwJM18WwDKQpESYIoRGyxVaLH0MK+jrzGIZgolJH6YuzZts2cRwThaFsYfs/wNE25vkx4JPA5vaiZikz5ALgNWgtUbEgacY0pqapj40zM3KMmTPHqY2OEDfqhNUp0jhEpylCSBDg967CLfaS6+mnuG4T+YFV+L195Pq6sXImwlAZKQJ68j5oKFXrC9US5pjruiRxjFJqcwvjv21/J2aN/U1ki4qNfj6/ZOATQjDQWaDoey3QAhVLwkqT8rHjTBw+xJnB5ygPH6JeOk1UK6OSGCUE2jDBMKD9b5IgkhiRppDGCCExbBfL7yTX3U/fFXtYtetGVuy4is61q7ByBtLUaBTjlRpTtQZL2axZ4RjZcBia7wF3AhtN01zWfN/le3TmPLQSpKGgcqrEqZ89y8mf7mP0tecJp0ukUYC2bXR3P2rr1aiuFaj1W1H5Tsh3gGGiTQuUQoRNRH0GOXYKefYkxtnjJKUzNIfOMjk0yJHH/4L8yg2suupGLrv5l+m/6ipy3T49uTxBqGkEEYi0FdUWGAqOQxRFKKU2trD+t9ke0AX8ANib832cJdzftUzWdHUhU4vpk6Mce/pxjuz7FhNDB0nCJn6hQLRqI8G6rSTrt6LXb0V39oDng2VnAc9s9bwh6fJsmomiqQXEETSq0Kgjj7+B+eoLmMOvISfHoFkHIXA6ull51U1s//An6Nt6JRMjxxgdO0Nx5y8g/cXrEI16nTBbL7wAfAgotz3gGmCnlHLJ3hcIutwC4UTIsaf/hkN/9SdMvHWAJI5Yu24d//hDt+Nf8z7+sF4kyhXBzYPjstKzuabXY1PB4mAl5fmqohZrul2DDw24vDwdc6iaIJRCxCF5S1Jbv5no6huJS2eQp45i/uxJnMMvEVSmOP6Tv2bs0PN0rLyMZnmMdR/8GGLnLtCKbHZvzSxoEAoEWLZNFEVorXe2MO9rE3Ab4JlLVHK0Bk+41A6f5OA3vszxnzxG1KhS7OrmV+76KJ/6jU/grN/Ex/afoOoYyEIXOcfhV/ot/uUai205iS2hnmq+N5Hy745FuK5FpyXpdi1oglSKXb0e6zyDfSWfWr6I7ugm7e4nvWwHG+pnuXLwCfb/6CmmJ87QmDqLm+/GL3QhYoEOLFQQEk3PEE6O4a5YgbumB4TGNE2klKRp6rUw7zNb7n8DcPHe1wIRGky98iJvfP0BJo4exLJtbr7lZn7rvs/y/vffgmFZfGb/MfaXmrByA45rc+96h99dKekyz8/beUPwa/0mUsDXJgWJgg4TZJqyJmeyu8OkFCmiRGW9WOgCywHXY6S/n9/5pVu492OH+PIffYWnnv4xYX2Gwf/5H+k7+BxmoZP6mWEaZ4/hr9rElk/+q5Y3aIQQmJbVToxuALpMsnrepnbldkFTAt0UlJ59mqG/eJDq6DG6e3r4zGc+zac+9c/p6ekB4JnxGn95fAr8IhgmpoDbC8wBP9vu6DUZBd4MICcFK2zJnmL23FioEGjWOIJ1eYuZ1OF1w6QxPsIfn6jyww+8n69fdy3f/Ob/4tFH/4hjx45z6sffBjRucRV9uz7Emtt+FadnAFVL0CpB+iamadLKIzcBm40Nd9/7S8BHDMOQCxY5NOim5OwTP+StP/sCjcnTbN++nQcffICPf/yj+L4PQKo1v/d6if1nK5DvBDfHClPz26ttuq2FCbAEDNeavDQd4hgGmztseh2BEDAVaTYXLPb0OKxyDaopjAQa3agyPlNja9Fj70CR9+zZzU3/6H3MVCocHRomSVPc4gCmm2dm6AATL/+E0Wf+hrhcoWPzdoSdVZG01g7wskm2Y2MahrEgeGKTqQMvcvRbXySYHmfvddfx0EMPsGvXVXNuHalHPH2qDLk8VkcRwxCkQtC4SI6igR+9MMhfDQ7TXexgx44r2HL5RhzTYEeHgQBSDW9UU16cjlEIyHeRNOo8fnqaezZ2YQrBjh3beeSRP2Dv3mv54hf/kFMjh6mPHkEgMLwCxa17KO7YjXQMtNRIKVFKmcDVJtl2FXIh91cGtSMnGPrGQwTTY9z6/lt58MH72bp1ywW3vlhuMhKk0DNAr+9xXdEErZlQi+fsk42AwVNjpLUapWqFH4+corR7Fze89xosw6Aca16cjjnZTMEw8QyDQBTRQZ39pTpDtZhthSxh832fT3/6N7jiim3c/4X/ynPP7Se/7hfY8qv/mo4d2zCLDsgUgZgd6LdKsr26C6O/kkRjNYa//SjTwwe58cYbePjhLy0IHuDNSkisBdgupTDhSD3BkILn6oKx5EI30MBXXzrCwZFx2qsgnaa8fuAAg6+/RS2B001FwYAP9jvc2W9yR7/Jmg4H/AJng5Sh2oWrwptuupE//bOv8bGPf5S0WqJ29ijB+Cj1Y6cIz5ZBzSFgrQn0iPkVGg2qCSPf/wajP3ucPXvew4MPPcCGDesX7c3RIM1WNCol0RavVRJONBVTsQkY/GKnZoOlIYoYma7zndeGeWT/IeJUgVKgsnW+jmLeeu0QGzZtZnPexDWyoRBqONFUVJOsgUmSUGrGC7alr6+XL3zh91g5MMDDjz7I8e95+Ks3s+nX7sMZ6J6Ntcck26WdS4AyqA0Nc/rH32Hj+rXc/8Dvc+WVOxYFH2vN6XoIqaJoCrZ1WUgJzUQTKTgawNcTwczYKIPP7udsucJEI+RcOUIpkEZGgtYE9ToyCfHzJpUY3qymDDcSxiKd1SuTBK0U1TBetE2FQp777vssh159hZ8OldnwT36bju2bQSqEPIfVN2ltUZ+7pEE1Uk49/i2MoMzn/vP9XH/9dSxlQmtwPITt0GNBlyXQSPKmwGmFlxP1mFdHy1m21nZDTfb/9r6faaGkQaqzvxUQao1vCDbmJKVE0Cx0kpRL1OOLV4fyhQLX7r2W042/xc+5rYbOQWtdWBLTkmCsxNShn/JP77qTe+65e0nwhhB0mNmivhzE/GCkiWVZOLbJJt9kT9HEMaCY8zBclzQMs95WKhs2hpGRIAQohd/Zge+6pAo8Cdd2ZblBrOBoQ/NMLSUBOtylS3XrN2zAqH2f8KmvIPK/hbl+xZzvTbJqqX0uTKUG1cOv0uOkXH/9e5e1MpRAr+9AWIEwxLRM1vgWWwoW/Y7AAJIUVvZ1s2btak68eTgD267v2U5GiBBgO2y7YgsdnoXQUIo0B6ZThIAwUdSihLA8gSM1G3Lmkm0rFotZglefgiignRW2LDaButba1lqDFqhqhHnqVVau6MLL5ZZ8Qdu2d7oInWKIlOtXdrG9YGAAsYZAgUFW5Nx19U4mz45SK5eznp8TexSrN2/kyi2XIwClod8WbM5JXpqOmYhUtloMA3pci00d7kXbpJKEk8eOo1SKObAN2dMPQs0uoNRNMllKl1YKYkly+ADO5GFSQ1Kv15ZNwDU9Pv0FjzEhGY0FqqaoJ5rpRJMoxc3dFiSC3p4ebrrtAwy+9DLjp0+TRBFohZvPs3HrVnZftQPHskhSKIWKRgprXEmu22JfKaSRSJAGO7ss1vqLD4Gg0eS5p55i8MABZNdarOvuQHS6INLZZbRJk0yTs0kphVaKdPglCKuAoDReImw0MSwTc4mhsDlvc/0Kn+9WFEeamsP1FITEFZo9BQPXEGidZXZr+vsY+OAHGC9PM1OrY5uS3s4OCvk8lhTZpCBgOlY8OxnTaWXPBkEESQw64YNr+sgZC69ca5UKzzzxJG8ceo24cw3OzvdhblyHMLMhp86X1keMDXffex2wR0oDy3LQsURXy5AmVMtlahMTHH/rLcZOnSZNEnK+vyAZphD4huSxo6NESITtYku4qdvkirykmmhKkcY3BZYE1xR0+R4rujrp6cjqipYUhAoqqcaRgg5LMpnAyVpMLW257tQYmx3N569eTbd9YfY6MznFvu//b4aHhmgW13Ny062ka/oRjmoNfz17//Bxk0yNlaQqNTFTrJ27MDZsQ01NMNOo8qyUdE+W6B55g1cGB+kfGGDn7t1cvnUrbs6b8/JbVvjc1uvy2FgJ7eVZ6dn0mfD8VMxwI6XDNljpSoSAmViTMwRSwGSo0Wh6bEmQap6eiFjjSHZ2WlzdYVKOUiabCYQBNKr8syvWcnn+QvcfO32GZ598kpMnTjKZX8/wqmtpdvtIK52Vcqi21CYBBo0Nd99rAHeitW/bFtIWyJyF0VNErOin0d1LubiWsrsSlERNneHM8WFGT51Gpyn5jgJWq4BqS8FlBYcnzsww0whJpcFwJBiup4QK9nZbrLAFg9WU58sxq1wD3xQ8X054fSZibc7EloKhasLhRko1yUgJU02pPA2VSd43UOCB3aspmOfdX6Upr79ykP/7gx8wXpqgVLiMoXXXEvXkkJaaswEYxzFxFNGKfV8yNtx9b4OsPrbWMAxM02xVlDRCKoSpsyWk71POryb1VuCmEc2J05wcHubMyRFQipzvYzsOq3MWHY7JkyfGqdWbNL1OAAqWZE+nyZlA8+xUTJhqVrkGSsGhasxEIuh3DQYcwdlmwkQzYSrWDNcTJksl1MQom3OCR65bz/ZZ0b9SLvP8Mz/hhWefo9KMOdWzkxPrdpMUHcQ88ADheWnNQeARY8Pd9wbANuB6hFi4HC5AGBptQ1joRq/YQmr4yEaZ6uQox48e5ehbQ5CmeF6OPQNFiq7F/vEqYaIgaOBKUKbN4ExMI05QStNjGwgEb1YjEiGoJ5oVjsFkAuPNBLRGBU3UxFm2dHl8+b0buHlFPuvJMOTNV1/jqR/uY3joCA2zg2P9ezizdhuqQyLMCxdgSimC8+P/28D32lXh24G/FlJ6hUJhyR2hXr9Ap5FDj0/hnXyV/OQQRmMSyzQodnWzcvUqVm/cyNNxjoeOTHO8kWQlrWIf+B2twiWsK7j0eQYvV9IsMscJedciSVKCRgNq0xA1uaHH4b/vXc97ujzCRpMzJ0/y6uAgJ44dI0ig1ruFE33bme4oIF0FcuEiRBRF1Gs1yDSHHwb2XVAW9zwP1/MuSoBlGKzpLmJLi7ihYbKMN/I63vhbeHEFFQdYlkXez3PKyjOocpywOhh1O5nu6CdGkhgm2iuAZWUJkVZQmYEkypKd+gwrRMxHNvbwL9bn8RsznDl1mpMnTjA9OUmiJUF+gJlVV1HqWcWEDsBIF1dVaE2tXm+P/wvK4mUy9eXeKIqwHeei1eE4TSlVa6zq6sQpCFSum6D3BprTO7FKZ/DG38KpnCGu1ihS4VYhCBHUhMXUWYfIzlEzbKpeEZ3roGZ7EIdQm8FBY8cBbhKy3VVsOvImz/1simazmeURpkvUfTmNgW1EfauJfJtKY/rccnoxS9KUJD63evxuCzMLbo15uRyue/E0E7L9ub7OfEa6BqUEaQyqniAqFazyGE5lDKM2gWhMIcMqlk7xbKuVj2fClaiVmCilssqtYaCUJlIKwy2QmB6h10VQGCDqWYPu7ccoWGhDMTozQy1ceru8XqsRZb0/Z2tMwBxtwO8Dv2sYBvlCYUm1pxCCvg4/26ic623ZbnAMKtboMIFmgKzPIMMAIwkxkxCiJsQhQivaIlhhOQjbJTVsYstF+50oL4/2XIRrYthgGJoUzdhMlZlGc0nwcRxTr9Xawe8LtDZHn75j1wW7w18F7krTdHMYBEsuhrTWTFTqoKE7nztXVBGtWUMagAM6b4IqoHQBrQSJ1sSqFQuVPq8LEIDMqsJIEFIgpcaQ2VY5IvOYRCnGZmrLAj8v8zvSwnjOTMgk56398qNkQsM/CMNQtgXPFzOlNaVqnUQpegs+ppRzdQ6i5WaGJptbFojQswmY8/3cewUQJAnjM8tXiYRhSJyNfdXCdrSN+dwrYWGJjGma+Pn8soXPOcemr+CTc5avKVqOiRbRlWbIRLVOlCxPMJUkCfVarZ36LiiRWUwlthf4DrDadhxyudyyJCmaTC7TmXMp+h6OaS5bHbYocKAZxZRrDapBuKQYom1KKeq1WltCexr4CC2R1HJUYi+QCY8fjsLQaStEl9PgVCmmag2qzZCC51BwHVzbyrSBs4ha7Pn294lSNKOYSiOgHkYkSi1bOKm1ptFozNYPf54FFGKz33nOFhJKCiFwPW9ZU+OchpDVCx3LJGfbuLaJbRoYbbFk6/UajVJZ4SRMEppRTDOKiZKseHEpitE2+Fm64eULJRcgoZMscNzzdkloE9F+mSEFUkhkWyqrzxOQKoXS+pJlshcB/+e0pLILgYdFhoDUCiUkrQc/Bzha67uajQZaqWXL5xZiOVWalBTSecF/qV5ZwpRSNBqNdqoLmVj6c23wYpGBd8lyecikZ57nLbyf+PdgSZLQPD/m4Z3K5RchYc6BCaMlQrwUGe27bVprwjAkDIL2VPfuHZhYhIQ5R2aEEFi2zXJFle+mxXFMEASzFzjv/pGZti11aEpKiW3b2I7zd0qE1po0TQmDoC1ygEUOTS12QOJtEdC2pY7NtVVmlm1jmua7dqRGKUWSJERRRHIeOPy8js1dhARY4OCkEALZqi+apnnJhydnH5pMkoQkSWbX8uHv6+DkEkQseHQWzh+fbX/EfDK0zkC3gLdL1/PS3n8YR2dn29s9PL1M+4d9eHq+/f96fP7/AVA/+mtM7QhMAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA4LTEyVDIwOjAxOjQ1KzAwOjAwrxRBGQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wOC0xMlQyMDowMTo0NSswMDowMN5J+aUAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjYtMDgtMTJUMjA6MDE6NTcrMDA6MDDSacnNAAAAAElFTkSuQmCC'; + + const COLOR_PRIMARY = '#17B9C7'; + const COLOR_SECONDARY = '#149DA9'; + const COLOR_TERTIARY = '#0F808A'; + + /* stolen from PM's animated text extension code :3 */ + const SANS_SERIF_ID = 'Sans Serif'; + const SERIF_ID = 'Serif'; + const HANDWRITING_ID = 'Handwriting'; + const MARKER_ID = 'Marker'; + const CURLY_ID = 'Curly'; + const PIXEL_ID = 'Pixel'; + + const PLAYFUL_ID = 'Playful'; + const BUBBLY_ID = 'Bubbly'; + const BITSANDBYTES_ID = 'Bits and Bytes'; + const TECHNOLOGICAL_ID = 'Technological'; + const ARCADE_ID = 'Arcade'; + const ARCHIVO_ID = 'Archivo'; + const ARCHIVOBLACK_ID = 'Archivo Black'; + const SCRATCH_ID = 'Scratch'; + + const RANDOM_ID = 'Random'; + + const FONT_IDS = [ + SANS_SERIF_ID, SERIF_ID, HANDWRITING_ID, MARKER_ID, CURLY_ID, PIXEL_ID, + PLAYFUL_ID, BUBBLY_ID, ARCADE_ID, BITSANDBYTES_ID, TECHNOLOGICAL_ID, + SCRATCH_ID, ARCHIVO_ID, ARCHIVOBLACK_ID + ]; + + // in case the rendering screws up: + const FONT_FALLBACKS = { + [SANS_SERIF_ID]: 'sans-serif', + [SERIF_ID]: 'serif', + [HANDWRITING_ID]: 'cursive', + [MARKER_ID]: 'fantasy', + [CURLY_ID]: 'cursive', + [PIXEL_ID]: 'monospace', + [PLAYFUL_ID]: 'cursive', + [BUBBLY_ID]: 'cursive', + [BITSANDBYTES_ID]: 'monospace', + [TECHNOLOGICAL_ID]: 'sans-serif', + [ARCADE_ID]: 'monospace', + [ARCHIVO_ID]: 'sans-serif', + [ARCHIVOBLACK_ID]: 'sans-serif', + [SCRATCH_ID]: 'sans-serif' + }; + + function cssFontFamily(fontId) { + const fallback = FONT_FALLBACKS[fontId] || 'sans-serif'; + return `"${fontId}", ${fallback}`; + } + + function randomFontOtherThan(currentFont) { + const others = FONT_IDS.filter(id => id !== currentFont); + return others[Math.floor(Math.random() * others.length)]; + } + + function buildFontMenuItems() { + const builtIns = [{ + text: 'Sans Serif', + value: SANS_SERIF_ID + }, + { + text: 'Serif', + value: SERIF_ID + }, + { + text: 'Handwriting', + value: HANDWRITING_ID + }, + { + text: 'Marker', + value: MARKER_ID + }, + { + text: 'Curly', + value: CURLY_ID + }, + { + text: 'Pixel', + value: PIXEL_ID + }, + { + text: 'Playful', + value: PLAYFUL_ID + }, + { + text: 'Bubbly', + value: BUBBLY_ID + }, + { + text: 'Arcade', + value: ARCADE_ID + }, + { + text: 'Bits and Bytes', + value: BITSANDBYTES_ID + }, + { + text: 'Technological', + value: TECHNOLOGICAL_ID + }, + { + text: 'Scratch', + value: SCRATCH_ID + }, + { + text: 'Archivo', + value: ARCHIVO_ID + }, + { + text: 'Archivo Black', + value: ARCHIVOBLACK_ID + } + ]; + + const customFonts = []; + document.fonts.forEach(face => { + if (!FONT_IDS.includes(face.family) && !customFonts.some(f => f.value === face.family)) { + customFonts.push({ + text: face.family, + value: face.family + }); + } + }); + + return [ + ...builtIns, + ...customFonts, + { + text: 'random font', + value: RANDOM_ID + } + ]; + } + + const KNOWN_TAGS = ['color', 'b', 'i', 'u', 's', 'size', 'font']; + const SELF_CLOSING_TAGS = ['wait']; + const TAG_RE = /\[(\/?)([a-z0-9_-]+)(?:=([^\]]+))?\]/gi; + const TAG_RE_STRIP = /\[(\/?)([a-z0-9_-]+)(?:=([^\]]+))?\]/gi; + + function parseRichText(text, base) { + const stack = [Object.assign({ + customTags: [] + }, base)]; + const out = []; + TAG_RE.lastIndex = 0; + let i = 0; + let match; + while ((match = TAG_RE.exec(text)) !== null || i < text.length) { + const tagStart = match ? match.index : text.length; + while (i < tagStart) { + const style = stack[stack.length - 1]; + out.push({ + char: text[i], + color: style.color, + bold: style.bold, + italic: style.italic, + underline: style.underline, + strike: style.strike, + size: style.size, + font: style.font, + tags: style.customTags + }); + i++; + } + if (!match) break; + const closing = match[1] === '/'; + const tag = match[2].toLowerCase(); + const value = match[3]; + if (SELF_CLOSING_TAGS.indexOf(tag) !== -1) { + i = match.index + match[0].length; + continue; + } + if (closing) { + if (stack.length > 1) stack.pop(); + } else { + const prev = stack[stack.length - 1]; + const top = { + color: prev.color, + bold: prev.bold, + italic: prev.italic, + underline: prev.underline, + strike: prev.strike, + size: prev.size, + font: prev.font, + customTags: prev.customTags + }; + if (KNOWN_TAGS.indexOf(tag) !== -1) { + switch (tag) { + case 'color': + top.color = value || top.color; + break; + case 'b': + top.bold = true; + break; + case 'i': + top.italic = true; + break; + case 'u': + top.underline = true; + break; + case 's': + top.strike = true; + break; + case 'size': + top.size = Number(value) || top.size; + break; + case 'font': + top.font = value === RANDOM_ID ? + randomFontOtherThan(prev.font) : + (value || top.font); + break; + } + } else { + top.customTags = top.customTags.concat([tag]); + } + stack.push(top); + } + i = match.index + match[0].length; + } + return out; + } + + function characterStyleSnapshot(style) { + return { + color: style.color, + bold: style.bold, + italic: style.italic, + underline: style.underline, + strike: style.strike, + size: style.size, + font: style.font, + tags: Array.isArray(style.tags) ? style.tags.slice() : [] + }; + } + + function applyCharacterStyleOverrides(chars, overrides) { + for (const index in overrides) { + const char = chars[index]; + const override = overrides[index]; + if (!char || !override) continue; + char.color = override.color; + char.bold = override.bold; + char.italic = override.italic; + char.underline = override.underline; + char.strike = override.strike; + char.size = override.size; + char.font = override.font; + char.tags = override.tags.slice(); + } + } + + function characterStyleFromJSON(value, base) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const style = characterStyleSnapshot(base); + if (typeof value.color === 'string') style.color = value.color; + if (typeof value.bold === 'boolean') style.bold = value.bold; + if (typeof value.italic === 'boolean') style.italic = value.italic; + if (typeof value.underline === 'boolean') style.underline = value.underline; + if (typeof value.strike === 'boolean') style.strike = value.strike; + if (Number.isFinite(value.size)) style.size = Math.max(1, value.size); + if (typeof value.font === 'string') style.font = value.font; + if (Array.isArray(value.tags) && value.tags.every(tag => typeof tag === 'string')) { + style.tags = value.tags.slice(); + } + return style; + } + + function stripMarkup(text) { + TAG_RE_STRIP.lastIndex = 0; + return text.replace(TAG_RE_STRIP, ''); + } + + function defaultBaseStyle() { + return { + color: '#9966ff', + bold: false, + italic: false, + underline: false, + strike: false, + size: 32, + font: HANDWRITING_ID, + customTags: [] + }; + } + + const WAIT_RE = /\[wait=([0-9]*\.?[0-9]+)\]/gi; + + function splitWaitStages(text) { + WAIT_RE.lastIndex = 0; + const stages = []; + let i = 0; + let match; + let textSoFar = ''; + while ((match = WAIT_RE.exec(text)) !== null) { + textSoFar += text.slice(i, match.index); + stages.push({ + text: textSoFar, + waitAfter: Math.max(0, Number(match[1]) || 0) + }); + i = match.index + match[0].length; + } + textSoFar += text.slice(i); + stages.push({ + text: textSoFar, + waitAfter: 0 + }); + return stages; + } + + function defaultState() { + return { + rawText: '', + finalText: '', + visible: false, + baseStyle: defaultBaseStyle(), + align: 'center', + smoothing: true, + letterSpacing: 0, + lineSpacing: 1.2, + typingSpeeds: { + default: 0.05, + symbols: 0.05, + punctuation: 0.15, + numbers: 0.05 + }, + customTypingSpeeds: {}, + maxWidth: null, + maxHeight: null, + textShadow: { + enabled: false, + color: '#000000', + opacity: 50, + blur: 8, + offsetX: 4, + offsetY: 4 + }, + textBackground: { + enabled: false, + color: '#000000', + opacity: 50, + radius: 8, + padding: 8 + }, + textBorder: { + enabled: false, + color: '#000000', + opacity: 100, + size: 2 + }, + charOverrides: {}, + charOverridesVersion: 0, + charStyleOverrides: {}, + charStyleOverridesVersion: 0, + typingControl: null, + skinId: null, + drawableWidth: 0, + drawableHeight: 0, + charBoxes: [], + charsByTag: new Map(), + shapeKey: null, + layoutKey: null, + layout: null, + charBoxesLayout: null, + paintDirty: true, + paintFingerprint: null, + lastFamiliesKey: null, + lastFontDefs: '', + revealToken: 0 + }; + } + + function getShapeKey(state) { + const base = state.baseStyle; + return [ + state.rawText, + base.color, + base.bold ? 1 : 0, + base.italic ? 1 : 0, + base.underline ? 1 : 0, + base.strike ? 1 : 0, + base.size, + base.font, + state.letterSpacing, + state.lineSpacing, + state.maxWidth || 0, + state.maxHeight || 0, + state.charStyleOverridesVersion + ].join('\u0001'); + } + + function getState(target) { + let state = target.getCustomState(STATE_KEY); + if (!state) { + state = defaultState(); + target.setCustomState(STATE_KEY, state); + } else { + const defaults = defaultState(); + state.textShadow = Object.assign(defaults.textShadow, state.textShadow || {}); + state.textBackground = Object.assign(defaults.textBackground, state.textBackground || {}); + state.textBorder = Object.assign(defaults.textBorder, state.textBorder || {}); + state.typingSpeeds = Object.assign(defaults.typingSpeeds, state.typingSpeeds || {}); + state.customTypingSpeeds = Object.assign({}, state.customTypingSpeeds || {}); + if (typeof state.smoothing !== 'boolean') state.smoothing = defaults.smoothing; + state.charStyleOverrides = Object.assign({}, state.charStyleOverrides || {}); + if (typeof state.charStyleOverridesVersion !== 'number') state.charStyleOverridesVersion = 0; + if (state.typingControl !== 'skip' && state.typingControl !== 'stop') state.typingControl = null; + } + return state; + } + + const TYPING_PUNCTUATION = '.,!?;:\'"…'; + const TYPING_SYMBOLS = '@#$%^&*+=<>/\\|~`_-()[]{}'; + + function typingGroupForCharacter(char) { + if (/^[0-9]$/.test(char)) return 'numbers'; + if (TYPING_PUNCTUATION.includes(char)) return 'punctuation'; + if (TYPING_SYMBOLS.includes(char)) return 'symbols'; + return 'default'; + } + + function typingDelayForCharacter(state, char) { + if (_hasOwn.call(state.customTypingSpeeds, char)) return state.customTypingSpeeds[char]; + return state.typingSpeeds[typingGroupForCharacter(char)]; + } + + function splitTypingSteps(text) { + const steps = []; + TAG_RE.lastIndex = 0; + let lastIndex = 0; + let match; + + const addCharacters = value => { + for (const char of Array.from(value)) { + steps.push({ + content: char, + char + }); + } + }; + + while ((match = TAG_RE.exec(text)) !== null) { + addCharacters(text.slice(lastIndex, match.index)); + steps.push({ + content: match[0], + char: null + }); + lastIndex = match.index + match[0].length; + } + addCharacters(text.slice(lastIndex)); + return steps; + } + + function exportTextSettings(state) { + return JSON.stringify({ + version: 1, + baseStyle: { + color: state.baseStyle.color, + bold: state.baseStyle.bold, + italic: state.baseStyle.italic, + underline: state.baseStyle.underline, + strike: state.baseStyle.strike, + size: state.baseStyle.size, + font: state.baseStyle.font + }, + align: state.align, + smoothing: state.smoothing, + letterSpacing: state.letterSpacing, + lineSpacing: state.lineSpacing, + typingSpeeds: Object.assign({}, state.typingSpeeds), + customTypingSpeeds: Object.assign({}, state.customTypingSpeeds), + maxWidth: state.maxWidth, + maxHeight: state.maxHeight, + textShadow: Object.assign({}, state.textShadow), + textBackground: Object.assign({}, state.textBackground), + textBorder: Object.assign({}, state.textBorder) + }); + } + + function applyTextSettings(state, settings) { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false; + + const base = settings.baseStyle; + if (base && typeof base === 'object' && !Array.isArray(base)) { + if (typeof base.color === 'string') state.baseStyle.color = base.color; + if (typeof base.bold === 'boolean') state.baseStyle.bold = base.bold; + if (typeof base.italic === 'boolean') state.baseStyle.italic = base.italic; + if (typeof base.underline === 'boolean') state.baseStyle.underline = base.underline; + if (typeof base.strike === 'boolean') state.baseStyle.strike = base.strike; + if (Number.isFinite(base.size)) state.baseStyle.size = Math.max(1, base.size); + if (typeof base.font === 'string') state.baseStyle.font = base.font; + } + if (settings.align === 'left' || settings.align === 'center' || settings.align === 'right' || settings.align === 'justify') { + state.align = settings.align; + } + if (typeof settings.smoothing === 'boolean') state.smoothing = settings.smoothing; + if (Number.isFinite(settings.letterSpacing)) state.letterSpacing = settings.letterSpacing; + if (Number.isFinite(settings.lineSpacing)) state.lineSpacing = Math.max(0, settings.lineSpacing); + if (settings.typingSpeeds && typeof settings.typingSpeeds === 'object' && !Array.isArray(settings.typingSpeeds)) { + for (const group of ['default', 'symbols', 'punctuation', 'numbers']) { + if (Number.isFinite(settings.typingSpeeds[group])) { + state.typingSpeeds[group] = Math.max(0, settings.typingSpeeds[group]); + } + } + } + if (settings.customTypingSpeeds && typeof settings.customTypingSpeeds === 'object' && !Array.isArray(settings.customTypingSpeeds)) { + state.customTypingSpeeds = {}; + for (const char in settings.customTypingSpeeds) { + if (Number.isFinite(settings.customTypingSpeeds[char])) { + state.customTypingSpeeds[char] = Math.max(0, settings.customTypingSpeeds[char]); + } + } + } + if (settings.maxWidth === null || Number.isFinite(settings.maxWidth)) state.maxWidth = settings.maxWidth; + if (settings.maxHeight === null || Number.isFinite(settings.maxHeight)) state.maxHeight = settings.maxHeight; + + const copySettings = (source, target, keys) => { + if (!source || typeof source !== 'object' || Array.isArray(source)) return; + for (const key of keys) { + if (typeof target[key] === 'boolean' && typeof source[key] === 'boolean') { + target[key] = source[key]; + } else if (typeof target[key] === 'number' && Number.isFinite(source[key])) { + target[key] = source[key]; + } else if (typeof target[key] === 'string' && typeof source[key] === 'string') { + target[key] = source[key]; + } + } + }; + + copySettings(settings.textShadow, state.textShadow, ['enabled', 'color', 'opacity', 'blur', 'offsetX', 'offsetY']); + copySettings(settings.textBackground, state.textBackground, ['enabled', 'color', 'opacity', 'radius', 'padding']); + copySettings(settings.textBorder, state.textBorder, ['enabled', 'color', 'opacity', 'size']); + state.shapeKey = null; + state.layoutKey = null; + state.layout = null; + state.paintDirty = true; + state.paintFingerprint = null; + return true; + } + + function getCharOverride(state, index) { + if (!state.charOverrides[index]) { + state.charOverrides[index] = { + x: 0, + y: 0, + rotation: 0, + opacity: 1 + }; + state.charOverridesVersion++; + } + return state.charOverrides[index]; + } + + const SVG_NS = 'http://www.w3.org/2000/svg'; + let measureSvg = null; + + function getMeasureSvg() { + if (measureSvg && document.body.contains(measureSvg)) return measureSvg; + measureSvg = document.createElementNS(SVG_NS, 'svg'); + measureSvg.setAttribute('width', '0'); + measureSvg.setAttribute('height', '0'); + measureSvg.style.position = 'absolute'; + measureSvg.style.visibility = 'hidden'; + measureSvg.style.pointerEvents = 'none'; + document.body.appendChild(measureSvg); + return measureSvg; + } + + const GLYPH_OVERSAMPLE = 3; + const GLYPH_PADDING_EM = 0.35; + const glyphCache = new Map(); + const glyphMeasureCanvas = document.createElement('canvas'); + const glyphMeasureCtx = glyphMeasureCanvas.getContext('2d', { + willReadFrequently: false + }); + try { + glyphMeasureCtx.textRendering = 'optimizeLegibility'; + } catch (e) {} + + function glyphCacheKey(char, fontFamily, size, weight, style, color) { + return char + '\u0001' + fontFamily + '\u0001' + size + '\u0001' + + weight + '\u0001' + style + '\u0001' + color; + } + + function rasterizeGlyph(char, fontFamily, size, weight, style, color) { + const px = Math.max(1, Math.round(size * GLYPH_OVERSAMPLE)); + const pad = Math.max(2, Math.round(size * GLYPH_PADDING_EM * GLYPH_OVERSAMPLE)); + const fontStr = `${style} ${weight} ${px}px ${fontFamily}`; + + glyphMeasureCtx.font = fontStr; + glyphMeasureCtx.textBaseline = 'alphabetic'; + const metrics = glyphMeasureCtx.measureText(char); + const ascent = Math.ceil( + (typeof metrics.fontBoundingBoxAscent === 'number' ? metrics.fontBoundingBoxAscent : null) ?? + metrics.actualBoundingBoxAscent ?? (px * 0.9) + ); + const descent = Math.ceil( + (typeof metrics.fontBoundingBoxDescent === 'number' ? metrics.fontBoundingBoxDescent : null) ?? + metrics.actualBoundingBoxDescent ?? (px * 0.3) + ); + const leftBearing = Math.ceil(Math.max(0, metrics.actualBoundingBoxLeft || 0)); + const rightBearing = Math.ceil(Math.max( + metrics.actualBoundingBoxRight || 0, + metrics.width || 0 + )); + const advance = metrics.width; + + const canvasW = leftBearing + rightBearing + pad * 2; + const canvasH = ascent + descent + pad * 2; + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, canvasW); + canvas.height = Math.max(1, canvasH); + const ctx = canvas.getContext('2d'); + ctx.font = fontStr; + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = color; + try { + ctx.textRendering = 'optimizeLegibility'; + } catch (e) {} + const baselineX = pad + leftBearing; + const baselineY = pad + ascent; + ctx.fillText(char, baselineX, baselineY); + + return { + canvas, + advance: advance / GLYPH_OVERSAMPLE, + baselineOriginXEm: baselineX / GLYPH_OVERSAMPLE, + baselineOriginYEm: baselineY / GLYPH_OVERSAMPLE, + fontAscentEm: ascent / GLYPH_OVERSAMPLE, + fontDescentEm: descent / GLYPH_OVERSAMPLE + }; + } + + function getGlyphBitmap(char, fontFamily, size, weight, style, color) { + const key = glyphCacheKey(char, fontFamily, size, weight, style, color); + let entry = glyphCache.get(key); + if (!entry) { + entry = rasterizeGlyph(char, fontFamily, size, weight, style, color); + glyphCache.set(key, entry); + } + return entry; + } + + function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { + const stroke = (x1, x2) => { + if (x2 <= x1) return; + ctx.beginPath(); + ctx.moveTo(x1, y); + ctx.lineTo(x2, y); + ctx.stroke(); + }; + + try { + const image = glyph.canvas.getContext('2d').getImageData(0, 0, glyph.canvas.width, glyph.canvas.height); + const rowStart = Math.max(0, Math.floor(y - glyphY - lineWidth / 2)); + const rowEnd = Math.min(glyph.canvas.height - 1, Math.ceil(y - glyphY + lineWidth / 2)); + const columnStart = Math.max(0, Math.floor(startX - glyphX)); + const columnEnd = Math.min(glyph.canvas.width - 1, Math.ceil(endX - glyphX)); + const ink = new Uint8Array(glyph.canvas.width); + + for (let py = rowStart; py <= rowEnd; py++) { + for (let px = columnStart; px <= columnEnd; px++) { + if (image.data[(py * glyph.canvas.width + px) * 4 + 3] > 16) ink[px] = 1; + } + } + + const padding = Math.max(1, Math.ceil(lineWidth / 2) + 1); + let segmentStart = startX; + let px = columnStart; + while (px <= columnEnd) { + if (!ink[px]) { + px++; + continue; + } + const inkStart = px; + while (px <= columnEnd && ink[px]) px++; + const skipStart = Math.max(startX, glyphX + inkStart - padding); + const skipEnd = Math.min(endX, glyphX + px + padding); + stroke(segmentStart, skipStart); + segmentStart = Math.max(segmentStart, skipEnd); + } + stroke(segmentStart, endX); + } catch (e) { + stroke(startX, endX); + } + } + + function invalidateGlyphCacheForFamily(fontFamily) { + for (const key of glyphCache.keys()) { + if (key.indexOf('\u0001' + fontFamily + '\u0001') !== -1) glyphCache.delete(key); + } + } + + function svgFontStyle(style) { + const fontFamily = cssFontFamily(style.font); + const fontWeight = style.bold ? 'bold' : 'normal'; + const fontStyle = style.italic ? 'italic' : 'normal'; + const styleHash = ((hashString(fontFamily) * 33) ^ hashString(fontWeight) * 33 ^ hashString(fontStyle)) | 0; + return { + 'font-family': fontFamily, + 'font-size': style.size, + 'font-weight': fontWeight, + 'font-style': fontStyle, + 'styleHash': styleHash + }; + } + + function setAttrs(el, attrs) { + for (const key in attrs) { + el.setAttribute(key, attrs[key]); + } + } + + function sameShapingStyle(a, b) { + return a.font === b.font && a.size === b.size && + a.bold === b.bold && a.italic === b.italic && + a.color === b.color && a.underline === b.underline && a.strike === b.strike; + } + + const _hasOwn = Object.prototype.hasOwnProperty; + + function splitShapingRuns(line, charOverrides, startGlobalIndex) { + const runs = []; + let current = null; + let globalIndex = startGlobalIndex; + + for (const rc of line) { + const hasOverride = _hasOwn.call(charOverrides, globalIndex); + const breaksRun = !current || hasOverride || current.hasOverride || + !sameShapingStyle(current.style, rc); + + if (breaksRun) { + current = { + chars: [], + style: rc, + startIndex: globalIndex, + hasOverride + }; + runs.push(current); + } + current.chars.push(rc); + globalIndex += 1; + } + return runs; + } + + function measureShapedRuns(svg, runs, letterSpacing) { + const textEl = document.createElementNS(SVG_NS, 'text'); + const tspans = []; + for (const run of runs) { + const tspan = document.createElementNS(SVG_NS, 'tspan'); + run.fontStyle = svgFontStyle(run.style); + run.text = run.chars.map(rc => (rc.char === ' ' ? '\u00A0' : rc.char)).join(''); + setAttrs(tspan, run.fontStyle); + if (letterSpacing) tspan.setAttribute('letter-spacing', letterSpacing); + tspan.textContent = run.text; + textEl.appendChild(tspan); + tspans.push(tspan); + } + svg.appendChild(textEl); + + let globalOffset = 0; + for (let r = 0; r < runs.length; r++) { + const run = runs[r]; + const n = run.chars.length; + const positions = new Array(n); + const runOrigin = n > 0 ? + (safeStartX(textEl, globalOffset)) : + 0; + for (let i = 0; i < n; i++) { + const idx = globalOffset + i; + let shapedX = 0; + let advance; + try { + shapedX = textEl.getStartPositionOfChar(idx).x - runOrigin; + advance = textEl.getExtentOfChar(idx).width; + } catch (e) { + const before = i === 0 ? 0 : tspans[r].getSubStringLength(0, i); + const upTo = tspans[r].getSubStringLength(0, i + 1); + shapedX = before; + advance = upTo - before; + } + positions[i] = { + shapedX, + advance + }; + } + run.positions = positions; + run.runWidth = tspans[r].getComputedTextLength(); + globalOffset += n; + } + + svg.removeChild(textEl); + } + + function safeStartX(textEl, idx) { + try { + return textEl.getStartPositionOfChar(idx).x; + } catch (e) { + return 0; + } + } + + function measureRichText(svg, richChars, state) { + const hardLines = [ + [] + ]; + for (const rc of richChars) { + if (rc.char === '\n') { + hardLines.push([]); + } else { + hardLines[hardLines.length - 1].push(rc); + } + } + + let globalIndex = 0; + for (const line of hardLines) { + const runs = splitShapingRuns(line, state.charOverrides, globalIndex); + if (runs.length) measureShapedRuns(svg, runs, state.letterSpacing); + for (const run of runs) { + for (let i = 0; i < run.chars.length; i++) { + run.chars[i]._width = run.positions[i].advance; + } + } + globalIndex += line.length + 1; + } + + const lines = []; + const lineWidths = []; + const lineRuns = []; + const charsByTag = new Map(); + + let maxWidth = 0; + const wrapLimit = state.maxWidth && state.maxWidth > 0 ? state.maxWidth : Infinity; + + for (const hardLine of hardLines) { + let currentLine = []; + let currentLineWidth = 0; + + for (let i = 0; i < hardLine.length; i++) { + const rc = hardLine[i]; + const w = rc._width; + + if (currentLineWidth + w > wrapLimit && currentLine.length > 0) { + let spaceIdx = -1; + for (let j = currentLine.length - 1; j >= 0; j--) { + if (currentLine[j].char === ' ' || currentLine[j].char === '-') { + spaceIdx = j; + break; + } + } + + if (spaceIdx !== -1 && spaceIdx < currentLine.length - 1) { + const nextLineInitial = currentLine.splice(spaceIdx + 1); + currentLineWidth = currentLine.reduce((sum, c) => sum + c._width, 0); + + lines.push([...currentLine]); + lineWidths.push(currentLineWidth); + if (currentLineWidth > maxWidth) maxWidth = currentLineWidth; + + currentLine = nextLineInitial; + currentLineWidth = currentLine.reduce((sum, c) => sum + c._width, 0); + } else { + lines.push([...currentLine]); + lineWidths.push(currentLineWidth); + if (currentLineWidth > maxWidth) maxWidth = currentLineWidth; + + currentLine = []; + currentLineWidth = 0; + } + } + + currentLine.push(rc); + currentLineWidth += w; + } + + if (currentLine.length > 0) { + lines.push(currentLine); + lineWidths.push(currentLineWidth); + if (currentLineWidth > maxWidth) maxWidth = currentLineWidth; + } else if (hardLine.length === 0) { + lines.push([]); + lineWidths.push(0); + } + } + + globalIndex = 0; + let hardLineIndex = 0; + let charsProcessed = 0; + + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + const runs = splitShapingRuns(line, state.charOverrides, globalIndex); + + let runStartX = 0; + for (const run of runs) { + run.fontStyle = svgFontStyle(run.style); + run.runStartX = runStartX; + run.positions = []; + let charX = 0; + for (let i = 0; i < run.chars.length; i++) { + const w = run.chars[i]._width; + run.positions.push({ + shapedX: charX, + advance: w + }); + addTagIndex(charsByTag, run.startIndex + i, run.chars[i].tags); + charX += w; + runStartX += w; + } + run.runWidth = runStartX - run.runStartX; + } + + lineRuns.push(runs); + globalIndex += line.length; + charsProcessed += line.length; + + if (charsProcessed >= hardLines[hardLineIndex].length) { + globalIndex++; + charsProcessed = 0; + hardLineIndex++; + } + } + + return { + lines, + lineWidths, + maxWidth, + lineRuns, + charsByTag + }; + } + + function fingerprintPaintOps(paintOps, docW, docH) { + let hash = (docW * 2654435761) ^ (docH * 40503); + hash = (hash * 33) ^ paintOps.length; + for (let i = 0; i < paintOps.length; i++) { + const op = paintOps[i]; + hash = (hash * 33) ^ hashString(op.text); + hash = (hash * 33) ^ ((op.x * 100) | 0); + hash = (hash * 33) ^ ((op.y * 100) | 0); + hash = (hash * 33) ^ ((op.rotation * 100) | 0); + hash = (hash * 33) ^ ((op.opacity * 1000) | 0); + hash = (hash * 33) ^ hashString(op.color); + hash = (hash * 33) ^ op.font.styleHash; + hash = (hash * 33) ^ ((op.font['font-size'] * 100) | 0); + hash = (hash * 33) ^ ((op.letterSpacing * 100) | 0); + hash = (hash * 33) ^ (op.underline ? 1 : 0); + hash = (hash * 33) ^ (op.strike ? 1 : 0); + } + return hash | 0; + } + + function hashString(str) { + if (!str) return 0; + let h = 0; + for (let i = 0; i < str.length; i++) { + h = ((h << 5) - h + str.charCodeAt(i)) | 0; + } + return h; + } + + const pendingRenderTargets = new Set(); + const pendingCharacterThreadStarts = new Set(); + let renderFlushScheduled = false; + + function requestRenderFlush() { + if (renderFlushScheduled) return; + renderFlushScheduled = true; + Promise.resolve().then(flushPendingRenders); + } + + function scheduleRender(target) { + pendingRenderTargets.add(target); + requestRenderFlush(); + } + + function flushPendingRenders() { + renderFlushScheduled = false; + const renderTargets = new Set(pendingRenderTargets); + for (const target of pendingCharacterThreadStarts) renderTargets.add(target); + for (const target of renderTargets) { + const needsRender = pendingRenderTargets.delete(target); + const startsCharacterThreads = pendingCharacterThreadStarts.delete(target); + const state = getState(target); + if (needsRender && state.visible) renderTarget(target); + if (startsCharacterThreads && state.visible) { + startCharacterThreads(target); + } + } + if (pendingRenderTargets.size || pendingCharacterThreadStarts.size) requestRenderFlush(); + } + + function flushRenderIfDirty(target) { + if (!pendingRenderTargets.has(target)) return; + pendingRenderTargets.delete(target); + const state = getState(target); + if (state.visible) renderTarget(target); + if (pendingCharacterThreadStarts.delete(target) && state.visible) { + startCharacterThreads(target); + } + if (pendingRenderTargets.size || pendingCharacterThreadStarts.size) requestRenderFlush(); + } + + function scheduleTextRender(target) { + pendingCharacterThreadStarts.add(target); + const state = getState(target); + if (state.paintDirty || state.layoutKey !== getLayoutKey(state) || !state.layout || + state.skinId === null || pendingRenderTargets.has(target)) { + scheduleRender(target); + } else { + requestRenderFlush(); + } + } + + function schedulePaint(target, state) { + state.paintDirty = true; + state.paintFingerprint = null; + if (state.visible) scheduleRender(target); + } + + function getLayoutKey(state) { + return getShapeKey(state) + '\u0002' + state.charOverridesVersion; + } + + const EMPTY_OVERRIDE = { + x: 0, + y: 0, + rotation: 0, + opacity: 1 + }; + + function addTagIndex(charsByTag, index, tags) { + if (!tags || !tags.length) return; + for (let t = 0; t < tags.length; t++) { + const tag = tags[t]; + let list = charsByTag.get(tag); + if (!list) { + list = []; + charsByTag.set(tag, list); + } + list.push(index); + } + } + + function renderTarget(target) { + const state = getState(target); + if (!state.visible) return; + + const shapeKey = getShapeKey(state); + let layout = state.layout; + if (state.shapeKey !== shapeKey || !layout) { + const richChars = parseRichText(state.rawText, state.baseStyle); + applyCharacterStyleOverrides(richChars, state.charStyleOverrides); + for (const family of new Set(richChars.map(rc => rc.font))) { + ensureDocumentFont(family); + } + const svg = getMeasureSvg(); + const measured = measureRichText(svg, richChars, state); + layout = measured; + state.shapeKey = shapeKey; + state.layout = layout; + } + state.layoutKey = getLayoutKey(state); + const { + lines, + lineWidths, + maxWidth, + lineRuns + } = layout; + + const lineHeight = state.baseStyle.size * state.lineSpacing; + const totalHeight = lines.length * lineHeight; + + const decorationPad = Math.max( + 0, + Number(state.textBackground.padding) || 0, + Number(state.textBorder.size) || 0, + (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetX) || 0), + (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetY) || 0) + ); + const pad = Math.max(64, state.baseStyle.size * 2, decorationPad + state.baseStyle.size); + + const effectiveMaxWidth = state.maxWidth && state.maxWidth > 0 ? state.maxWidth : maxWidth; + const effectiveMaxHeight = state.maxHeight && state.maxHeight > 0 ? state.maxHeight : totalHeight; + + const docW = Math.ceil(effectiveMaxWidth + pad * 2); + const docH = Math.ceil(effectiveMaxHeight + pad * 2); + + const originX = docW / 2; + const originY = docH / 2; + + let charBoxes = state.charBoxes; + if (state.charBoxesLayout !== layout) { + charBoxes = []; + state.charBoxes = charBoxes; + state.charBoxesLayout = layout; + } + const charsByTag = layout.charsByTag; + const paintOps = []; + const letterSpacing = state.letterSpacing || 0; + + for (let li = 0; li < lines.length; li++) { + const runs = lineRuns[li]; + const lineW = lineWidths[li]; + const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && + effectiveMaxWidth > lineW ? + line.filter(rc => rc.char === ' ').length : 0; + const justifyExtra = justifySpaceCount > 0 ? + (effectiveMaxWidth - lineW) / justifySpaceCount : 0; + let lineX; + + if (state.align === 'left') { + lineX = originX - effectiveMaxWidth / 2; + } else if (state.align === 'right') { + lineX = originX + effectiveMaxWidth / 2 - lineW; + } else if (state.align === 'justify') { + lineX = originX - effectiveMaxWidth / 2; + } else { + lineX = originX - lineW / 2; + } + + const textStartY = originY - totalHeight / 2; + const baseCenterY = textStartY + (li * lineHeight) + lineHeight * 0.5; + + let spacesBefore = 0; + for (const run of runs) { + const rc0 = run.chars[0]; + + const underline = !!rc0.underline; + const strike = !!rc0.strike; + const style = run.fontStyle; + const runOriginX = lineX + run.runStartX; + + for (let i = 0; i < run.chars.length; i++) { + const rc = run.chars[i]; + const pos = run.positions[i]; + const index = run.startIndex + i; + const o = run.hasOverride ? + (state.charOverrides[index] || EMPTY_OVERRIDE) : + EMPTY_OVERRIDE; + + const charCenterX = runOriginX + pos.shapedX + pos.advance / 2 + + spacesBefore * justifyExtra + o.x; + const charCenterY = baseCenterY - o.y; + const rotation = o.rotation || 0; + const opacity = o.opacity == null ? 1 : o.opacity; + const ch = rc.char === ' ' ? '\u00A0' : rc.char; + + paintOps.push({ + text: ch, + x: charCenterX, + y: charCenterY, + rotation, + opacity, + color: rc.color, + font: style, + rawFontFamily: rc0.font, + letterSpacing, + underline, + strike, + width: pos.advance + }); + + if (rc.char === ' ') spacesBefore++; + + let box = charBoxes[index]; + if (!box) { + box = { + index, + char: rc.char, + tags: rc.tags, + width: pos.advance, + height: rc.size, + style: characterStyleSnapshot(rc) + }; + charBoxes[index] = box; + } + box.style = characterStyleSnapshot(rc); + box.x = charCenterX - originX; + box.y = originY - charCenterY; + } + } + } + + state.charBoxes = charBoxes; + state.charsByTag = charsByTag; + state.drawableWidth = docW; + state.drawableHeight = docH; + + const fingerprint = fingerprintPaintOps(paintOps, docW, docH); + if (fingerprint === state.paintFingerprint && state.skinId !== null) { + state.paintDirty = false; + return; + } + state.paintFingerprint = fingerprint; + + const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); + pushCanvasToDrawable(target, canvas, docW, docH); + state.paintDirty = false; + } + + const loadedDocumentFonts = new Set(); + const pendingDocumentFonts = new Map(); + + function rerenderVisibleTextTargets() { + for (const candidate of runtime.targets) { + const state = candidate.getCustomState(STATE_KEY); + if (!state || !state.visible) continue; + state.shapeKey = null; + state.layoutKey = null; + state.layout = null; + state.paintFingerprint = null; + scheduleRender(candidate); + } + } + + function ensureDocumentFont(fontId) { + if (!document.fonts || loadedDocumentFonts.has(fontId) || pendingDocumentFonts.has(fontId)) return; + const promise = document.fonts.load(`16px ${cssFontFamily(fontId)}`, 'M') + .then(() => { + loadedDocumentFonts.add(fontId); + invalidateGlyphCacheForFamily(cssFontFamily(fontId)); + rerenderVisibleTextTargets(); + }) + .catch(() => {}) + .finally(() => { + pendingDocumentFonts.delete(fontId); + }); + pendingDocumentFonts.set(fontId, promise); + } + + const DEST_SCALE = GLYPH_OVERSAMPLE; + + function compositeGlyphsToCanvas(state, paintOps, docW, docH, textWidth, textHeight, originX, originY) { + let canvas = state.paintCanvas; + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.reusable = false; + state.paintCanvas = canvas; + state.paintCtx = canvas.getContext('2d'); + } + const pixelW = Math.max(1, Math.round(docW * DEST_SCALE)); + const pixelH = Math.max(1, Math.round(docH * DEST_SCALE)); + if (canvas.width !== pixelW || canvas.height !== pixelH) { + canvas.width = pixelW; + canvas.height = pixelH; + } + const ctx = state.paintCtx; + ctx.imageSmoothingEnabled = !!state.smoothing; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, pixelW, pixelH); + ctx.textBaseline = 'alphabetic'; + + const background = state.textBackground; + if (background.enabled) { + const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; + const width = textWidth * DEST_SCALE + padding * 2; + const height = textHeight * DEST_SCALE + padding * 2; + const x = originX * DEST_SCALE - width / 2; + const y = originY * DEST_SCALE - height / 2; + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; + ctx.fillStyle = background.color; + const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.fill(); + ctx.restore(); + } + + for (let i = 0; i < paintOps.length; i++) { + const op = paintOps[i]; + if (op.text === '\u00A0' || op.text === '') continue; + + const fontFamily = op.font['font-family']; + const fontSize = op.font['font-size']; + const fontWeight = op.font['font-weight']; + const fontStyle = op.font['font-style']; + + const glyph = getGlyphBitmap(op.text, fontFamily, fontSize, fontWeight, fontStyle, op.color); + + const advanceCenterXEm = glyph.baselineOriginXEm + glyph.advance / 2; + const baselineYFromLineCenter = (glyph.fontAscentEm - glyph.fontDescentEm) / 2; + const drawXEm = op.x - advanceCenterXEm; + const drawYEm = (op.y + baselineYFromLineCenter) - glyph.baselineOriginYEm; + + const hasOpacity = op.opacity != null && op.opacity !== 1; + const hasRotation = !!op.rotation; + + const drawGlyph = (drawX, drawY) => { + ctx.drawImage(glyph.canvas, drawX, drawY); + }; + + if (state.textShadow.enabled) { + ctx.save(); + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; + ctx.shadowColor = state.textShadow.color; + ctx.shadowBlur = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; + ctx.shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE; + ctx.shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE; + if (!hasRotation) { + drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + } else { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.translate(cx, cy); + ctx.rotate(-op.rotation * Math.PI / 180); + ctx.translate(-cx, -cy); + drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + } + ctx.restore(); + } + + if (op.underline || op.strike) { + ctx.save(); + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + ctx.lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + if (op.strike) { + const sy = op.y * DEST_SCALE; + ctx.beginPath(); + ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); + ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); + ctx.stroke(); + } + ctx.restore(); + } + + if (state.textBorder.enabled && Number(state.textBorder.size) > 0) { + ctx.save(); + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textBorder.opacity) || 0)) / 100; + ctx.shadowColor = state.textBorder.color; + ctx.shadowBlur = 0; + const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; + const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; + for (const [offsetX, offsetY] of borderOffsets) { + if (!hasRotation) { + drawGlyph(drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); + } else { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-op.rotation * Math.PI / 180); + ctx.translate(-cx, -cy); + drawGlyph(drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); + ctx.restore(); + } + } + ctx.restore(); + } + + if (!hasRotation) { + const px = Math.round(drawXEm * DEST_SCALE); + const py = Math.round(drawYEm * DEST_SCALE); + if (hasOpacity) { + ctx.save(); + ctx.globalAlpha = op.opacity; + ctx.drawImage(glyph.canvas, px, py); + ctx.restore(); + } else { + ctx.drawImage(glyph.canvas, px, py); + } + } else { + ctx.save(); + if (hasOpacity) ctx.globalAlpha = op.opacity; + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.translate(cx, cy); + ctx.rotate(-op.rotation * Math.PI / 180); + ctx.translate(-cx, -cy); + ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + ctx.restore(); + } + + if (op.underline) { + ctx.save(); + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + const lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + ctx.lineWidth = lineWidth; + const uy = (op.y + baselineYFromLineCenter + fontSize * 0.06) * DEST_SCALE; + const startX = (op.x - op.width / 2) * DEST_SCALE; + const endX = (op.x + op.width / 2) * DEST_SCALE; + if (hasRotation) { + ctx.beginPath(); + ctx.moveTo(startX, uy); + ctx.lineTo(endX, uy); + ctx.stroke(); + } else { + strokeUnderline(ctx, glyph, Math.round(drawXEm * DEST_SCALE), Math.round(drawYEm * DEST_SCALE), startX, endX, uy, lineWidth); + } + ctx.restore(); + } + + } + + return canvas; + } + + function installLinearFiltering(skin, state) { + skin.useNearest = function(scale) { + if (!state.smoothing) return true; + const s = Math.max(Math.abs(scale[0]), Math.abs(scale[1])); + return s >= (DEST_SCALE * 100 - 5); + }; + } + + function pushCanvasToDrawable(target, canvas, width, height) { + const state = getState(target); + const renderer = runtime.renderer; + if (!renderer) return; + const rotationCenter = [width / 2, height / 2]; + + if (state.skinId === null) { + const BitmapSkin = renderer.exports.BitmapSkin; + const skinId = renderer._nextSkinId++; + const skin = new BitmapSkin(skinId, renderer); + installLinearFiltering(skin, state); + skin.setBitmap(canvas, DEST_SCALE, rotationCenter); + renderer._allSkins[skinId] = skin; + state.skinId = skinId; + } else { + renderer.updateBitmapSkin(state.skinId, canvas, DEST_SCALE, rotationCenter); + const current = renderer._allSkins[state.skinId]; + if (current && !Object.prototype.hasOwnProperty.call(current, 'useNearest')) { + installLinearFiltering(current, state); + } + } + renderer.updateDrawableSkinId(target.drawableID, state.skinId); + runtime.requestRedraw(); + } + + function clearTarget(target) { + const state = getState(target); + state.visible = false; + state.revealToken++; + state.paintDirty = true; + state.paintFingerprint = null; + pendingCharacterThreadStarts.delete(target); + const costume = target.getCostumes()[target.currentCostume]; + if (costume && runtime.renderer) { + runtime.renderer.updateDrawableSkinId(target.drawableID, costume.skinId); + runtime.requestRedraw(); + } + stopCharacterHatThreads(target); + } + + const charScripts = {}; + + function stopCharacterHatThreads(target) { + target._irisCharSequenceActive = false; + const threads = target._irisCharSequenceThreads; + if (threads && threads.length) { + const ownedThreads = new Set(threads); + for (let i = runtime.threads.length - 1; i >= 0; i--) { + const thread = runtime.threads[i]; + if (ownedThreads.has(thread)) { + thread.isKilled = true; + runtime.threads.splice(i, 1); + } + } + } + target._irisCharSequenceThreads = []; + target._irisCharThreadCache = null; + } + + function createBlocksSnapshot(sourceBlocks, rootBlockId, rt) { + const BlocksClass = sourceBlocks.constructor; + const snapshot = new BlocksClass(rt, true); + snapshot.forceNoGlow = true; + + const sourceMap = sourceBlocks._blocks; + const visited = Object.create(null); + + function copyBlock(id) { + if (!id || visited[id] || !sourceMap[id]) return; + visited[id] = true; + const original = sourceMap[id]; + const clone = JSON.parse(JSON.stringify(original)); + snapshot._blocks[id] = clone; + + if (clone.inputs) { + for (const inputKey in clone.inputs) { + const input = clone.inputs[inputKey]; + if (input && input.block) { + copyBlock(input.block); + } + if (input && input.shadow && input.shadow !== input.block) { + copyBlock(input.shadow); + } + } + } + + if (clone.next) { + copyBlock(clone.next); + } + + if (clone.opcode === 'procedures_call' && clone.mutation && clone.mutation.proccode) { + const defId = sourceBlocks.getProcedureDefinition(clone.mutation.proccode); + if (defId) { + copyBlock(defId); + } + } + } + + copyBlock(rootBlockId); + + if (snapshot._blocks[rootBlockId]) { + snapshot._blocks[rootBlockId].parent = null; + snapshot._blocks[rootBlockId].topLevel = true; + } + snapshot._scripts = [rootBlockId]; + + return snapshot; + } + + const CHAR_REPORTER_OPCODES = ['irisText_currentCharValue', 'irisText_currentCharIndex']; + + function stackUsesCharReporter(blocks, blockId) { + const seen = Object.create(null); + + function walk(id) { + if (!id || seen[id]) return false; + seen[id] = true; + const block = blocks.getBlock(id); + if (!block) return false; + if (CHAR_REPORTER_OPCODES.indexOf(block.opcode) !== -1) return true; + + if (block.inputs) { + for (const key in block.inputs) { + const input = block.inputs[key]; + if (input && input.block && walk(input.block)) return true; + if (input && input.shadow && input.shadow !== input.block && walk(input.shadow)) return true; + } + } + if (block.opcode === 'procedures_call' && block.mutation && block.mutation.proccode) { + const defId = blocks.getProcedureDefinition(block.mutation.proccode); + if (defId && walk(defId)) return true; + } + if (walk(block.next)) return true; + return false; + } + + return walk(blockId); + } + + let ThreadClass = null; + + function createCharacterThread(entry, box) { + if (!ThreadClass) { + ThreadClass = runtime.threads.length > 0 ? runtime.threads[0].constructor : null; + } + if (!ThreadClass) return null; + const thread = new ThreadClass(entry.stack || 'irisText_char'); + thread.target = entry.target; + thread.stackClick = false; + thread.updateMonitor = false; + thread.blockContainer = entry.blocks || entry.target.blocks; + thread._irisChar = box; + thread.pushStack(entry.stack || 'irisText_char'); + if (entry.func) { + thread.procedures = Object.assign({}, entry.proc); + thread.generator = entry.func(thread, entry.target, runtime, runtime.getTargetForStage ? runtime.getTargetForStage() : null); + thread.isCompiled = true; + } else if (entry.compiledResult) { + const result = entry.compiledResult; + thread.procedures = {}; + if (result.procedures) { + for (const procedureCode of Object.keys(result.procedures)) { + thread.procedures[procedureCode] = result.procedures[procedureCode](thread); + } + } + if (typeof result.startingFunction === 'function') { + thread.generator = result.startingFunction(thread)(); + } + thread.executableHat = result.executableHat; + thread.isCompiled = true; + } else if (typeof thread.tryCompile === 'function' && runtime.compilerOptions && runtime.compilerOptions.enabled) { + thread.tryCompile(); + } + runtime.threads.push(thread); + if (runtime.threadMap && typeof thread.getId === 'function') { + runtime.threadMap.set(thread.getId(), thread); + } + return thread; + } + + function restartCharacterThread(cached, entry, box) { + if (!ThreadClass) ThreadClass = cached.constructor; + const newThread = new ThreadClass(entry.stack || 'irisText_char'); + newThread.target = entry.target; + newThread.stackClick = false; + newThread.updateMonitor = false; + newThread.blockContainer = entry.blocks || entry.target.blocks; + newThread._irisChar = box; + newThread.pushStack(entry.stack || 'irisText_char'); + if (entry.func) { + newThread.procedures = Object.assign({}, entry.proc); + newThread.generator = entry.func(newThread, entry.target, runtime, runtime.getTargetForStage ? runtime.getTargetForStage() : null); + newThread.isCompiled = true; + } else if (entry.compiledResult) { + const result = entry.compiledResult; + newThread.procedures = {}; + if (result.procedures) { + for (const procedureCode of Object.keys(result.procedures)) { + newThread.procedures[procedureCode] = result.procedures[procedureCode](newThread); + } + } + if (typeof result.startingFunction === 'function') { + newThread.generator = result.startingFunction(newThread)(); + } + newThread.executableHat = result.executableHat; + newThread.isCompiled = true; + } else if (typeof newThread.tryCompile === 'function' && runtime.compilerOptions && runtime.compilerOptions.enabled) { + newThread.tryCompile(); + } + if (runtime.threadMap && typeof newThread.getId === 'function') { + runtime.threadMap.set(newThread.getId(), newThread); + } + const idx = runtime.threads.indexOf(cached); + if (idx !== -1) { + runtime.threads[idx] = newThread; + } else { + runtime.threads.push(newThread); + } + return newThread; + } + + function startCharacterThread(entry, box, threads, cache, nextCache, liveThreads) { + if (!entry.func && (!entry.blocks || entry.blocks.getBlock(entry.stack) === undefined)) return; + const cacheKey = entry.tag + '\u0001' + box.index; + const cached = cache && cache.get(cacheKey); + let thread; + if (cached && (entry.func || cached.topBlock === entry.stack) && liveThreads.has(cached)) { + thread = restartCharacterThread(cached, entry, box); + } else { + thread = createCharacterThread(entry, box); + } + if (!thread) return; + if (thread.status !== thread.constructor.STATUS_DONE) { + runtime.sequencer.stepThread(thread); + } + nextCache.set(cacheKey, thread); + threads.push(thread); + } + + function startCharacterThreadsForEntry(state, entry, threads, cache, nextCache, liveThreads) { + const indices = state.charsByTag.get(entry.tag); + if (!indices || !indices.length) return; + + if (entry.perChar) { + for (const idx of indices) { + const box = state.charBoxes[idx]; + if (box) startCharacterThread(entry, box, threads, cache, nextCache, liveThreads); + } + } else { + const box = state.charBoxes[indices[0]]; + if (box) startCharacterThread(entry, box, threads, cache, nextCache, liveThreads); + } + } + + function startCharacterThreads(target) { + const state = getState(target); + const name = target.id; + + const previousThreads = target._irisCharSequenceThreads; + const cache = target._irisCharThreadCache; + const nextCache = new Map(); + const liveThreads = new Set(runtime.threads); + + runtime.startHats('irisText_onRenderComplete', null, target); + + const scriptMap = charScripts[name]; + const threads = []; + target._irisCharSequenceThreads = threads; + + if (scriptMap) { + for (const tag in scriptMap) { + const entry = scriptMap[tag]; + if (entry) { + startCharacterThreadsForEntry(state, entry, threads, cache, nextCache, liveThreads); + } + } + } + + if (previousThreads && previousThreads.length) { + const reused = new Set(threads); + for (let i = runtime.threads.length - 1; i >= 0; i--) { + const thread = runtime.threads[i]; + if (!reused.has(thread) && previousThreads.indexOf(thread) !== -1) { + thread.isKilled = true; + runtime.threads.splice(i, 1); + } + } + } + + target._irisCharThreadCache = nextCache; + target._irisCharSequenceActive = threads.length > 0; + } + + function charThreadsStillActive(target) { + const threads = target._irisCharSequenceThreads; + if (!threads || !threads.length) { + target._irisCharSequenceActive = false; + return false; + } + const sequenceThreads = new Set(threads); + const active = []; + for (const thread of runtime.threads) { + if (sequenceThreads.has(thread)) active.push(thread); + } + target._irisCharSequenceThreads = active; + if (!active.length) { + target._irisCharSequenceActive = false; + return false; + } + return true; + } + + function startTypingCharacterHat(target, char, index) { + const current = { + char, + index + }; + target._irisTypingChar = current; + const threads = runtime.startHats('irisText_onCharacterType', null, target); + if (Array.isArray(threads)) { + for (const thread of threads) { + thread._irisChar = current; + } + } + target._irisTypingChar = null; + } + + class IrisText { + constructor() { + this._onTargetRemoved = this._onTargetRemoved.bind(this); + runtime.on('targetWasRemoved', this._onTargetRemoved); + runtime.on('PROJECT_STOP_ALL', () => { + runtime.targets.forEach(t => clearTarget(t)); + }); + runtime.ext_irisText = this; + if (typeof runtime.registerCompiledExtensionBlocks === 'function') { + runtime.registerCompiledExtensionBlocks('irisText', this.getCompileInfo()); + } + } + + _scheduleCompiledTag(tagInput, func, thread, target) { + const tag = Scratch.Cast.toString(tagInput).toLowerCase(); + const name = target.id; + if (charScripts[name] === undefined) { + charScripts[name] = Object.create(null); + } + charScripts[name][tag] = { + func: func, + proc: thread && thread.procedures ? Object.assign({}, thread.procedures) : {}, + target: target, + tag: tag, + perChar: true + }; + } + + getCompileInfo() { + return { + ir: { + scheduleTagBlocks: (generator, block) => ({ + kind: 'stack', + tag: generator.descendInputOfBlock(block, 'TAG'), + substack: generator.descendSubstack(block, 'SUBSTACK') + }), + currentCharValue: (generator, block) => ({ + kind: 'input' + }), + currentCharIndex: (generator, block) => ({ + kind: 'input' + }) + }, + js: { + scheduleTagBlocks: (node, compiler, imports) => { + const temp = compiler.source; + compiler.source = '(function*(thread, target, runtime, stage) {\n'; + if (node.substack) { + compiler.descendStack(node.substack, new imports.Frame(false)); + } + compiler.source += '})'; + const funcExpr = compiler.source; + compiler.source = temp; + compiler.source += `runtime.ext_irisText._scheduleCompiledTag(${compiler.descendInput(node.tag).asString()}, ${funcExpr}, thread, target);\n`; + }, + currentCharValue: (node, compiler, imports) => { + return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.char : (target && target._irisTypingChar ? target._irisTypingChar.char : ""))', imports.TYPE_STRING); + }, + currentCharIndex: (node, compiler, imports) => { + return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.index + 1 : (target && target._irisTypingChar ? target._irisTypingChar.index + 1 : 0))', imports.TYPE_NUMBER); + } + } + }; + } + + _onTargetRemoved(target) { + const state = target.getCustomState(STATE_KEY); + if (state && state.skinId !== null && runtime.renderer) { + runtime.renderer.destroySkin(state.skinId); + state.skinId = null; + } + stopCharacterHatThreads(target); + delete charScripts[target.id]; + } + + getInfo() { + return { + id: 'irisText', + name: 'Iris Text', + color1: COLOR_PRIMARY, + color2: COLOR_SECONDARY, + color3: COLOR_TERTIARY, + blockIconURI: blockIconURI, + blocks: [{ + blockType: Scratch.BlockType.LABEL, + text: 'Display' + }, + { + opcode: 'setText', + blockType: Scratch.BlockType.COMMAND, + text: 'show text [TEXT]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: DEFAULT_TEXT + } + } + }, + { + opcode: 'clearText', + blockType: Scratch.BlockType.COMMAND, + text: 'show sprite (hide text)' + }, + { + opcode: 'renderNow', + blockType: Scratch.BlockType.COMMAND, + text: 'refresh text' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Style' + }, + { + opcode: 'setBaseColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set text color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setBaseSize', + blockType: Scratch.BlockType.COMMAND, + text: 'set text size to [SIZE]', + arguments: { + SIZE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 32 + } + } + }, + { + opcode: 'setFont', + blockType: Scratch.BlockType.COMMAND, + text: 'set font to [FONT]', + arguments: { + FONT: { + type: Scratch.ArgumentType.STRING, + menu: 'FONT', + defaultValue: HANDWRITING_ID + } + } + }, + { + opcode: 'setAlign', + blockType: Scratch.BlockType.COMMAND, + text: 'align text [ALIGN]', + arguments: { + ALIGN: { + type: Scratch.ArgumentType.STRING, + menu: 'ALIGN', + defaultValue: 'center' + } + } + }, + { + opcode: 'setSmoothing', + blockType: Scratch.BlockType.COMMAND, + text: 'smoothing [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Typing' + }, + { + opcode: 'typeText', + blockType: Scratch.BlockType.COMMAND, + text: 'type [TEXT]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: DEFAULT_TEXT + } + } + }, + { + opcode: 'skipTyping', + blockType: Scratch.BlockType.COMMAND, + text: 'skip typing' + }, + { + opcode: 'stopTyping', + blockType: Scratch.BlockType.COMMAND, + text: 'stop typing' + }, + { + opcode: 'setTypingSpeed', + blockType: Scratch.BlockType.COMMAND, + text: 'set typing speed for [GROUP] characters to [SECONDS] seconds', + arguments: { + GROUP: { + type: Scratch.ArgumentType.STRING, + menu: 'TYPING_GROUP', + defaultValue: 'default' + }, + SECONDS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0.05 + } + } + }, + { + opcode: 'setCustomTypingSpeed', + blockType: Scratch.BlockType.COMMAND, + text: 'set typing speed for custom characters [CHARS] to [SECONDS] seconds', + arguments: { + CHARS: { + type: Scratch.ArgumentType.STRING, + defaultValue: '…' + }, + SECONDS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0.2 + } + } + }, + { + opcode: 'onCharacterType', + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: false, + text: 'on character type' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Shadow' + }, + { + opcode: 'setTextShadowEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text shadow [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextShadowColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextShadowOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'setTextShadowBlur', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow blur to [BLUR]', + arguments: { + BLUR: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + opcode: 'setTextShadowOffset', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow offset x [X] y [Y]', + arguments: { + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 4 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 4 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Background' + }, + { + opcode: 'setTextBackgroundEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text background [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextBackgroundColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextBackgroundOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'setTextBackgroundRadius', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background radius to [RADIUS]', + arguments: { + RADIUS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + opcode: 'setTextBackgroundPadding', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background padding to [PADDING]', + arguments: { + PADDING: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Border' + }, + { + opcode: 'setTextBorderEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text border [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextBorderColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextBorderOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'setTextBorderSize', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border size to [SIZE]', + arguments: { + SIZE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 2 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Size Constraints' + }, + { + opcode: 'setMaxWidth', + blockType: Scratch.BlockType.COMMAND, + text: 'set max width to [WIDTH]', + arguments: { + WIDTH: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + } + } + }, + { + opcode: 'setMaxHeight', + blockType: Scratch.BlockType.COMMAND, + text: 'set max height to [HEIGHT]', + arguments: { + HEIGHT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + } + } + }, + { + opcode: 'setMaxDimensions', + blockType: Scratch.BlockType.COMMAND, + text: 'set max width to [WIDTH] and max height to [HEIGHT]', + arguments: { + WIDTH: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + }, + HEIGHT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + } + } + }, + { + opcode: 'clearMaxDimensions', + blockType: Scratch.BlockType.COMMAND, + text: 'remove max width and height bounds' + }, + { + opcode: 'setLetterSpacing', + blockType: Scratch.BlockType.COMMAND, + text: 'set letter spacing to [SPACE]', + arguments: { + SPACE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Settings' + }, + { + opcode: 'exportSettings', + blockType: Scratch.BlockType.REPORTER, + text: 'export settings as JSON' + }, + { + opcode: 'applyExportSettings', + blockType: Scratch.BlockType.COMMAND, + text: 'apply JSON export settings [SETTINGS] to [SPRITE]', + arguments: { + SETTINGS: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"version":1}' + }, + SPRITE: { + type: Scratch.ArgumentType.STRING, + menu: 'SPRITE', + defaultValue: '_myself_' + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Per-Character Scripting' + }, + { + opcode: 'onRenderComplete', + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: true, + text: 'when render finishes' + }, + { + opcode: 'scheduleTagBlocks', + blockType: Scratch.BlockType.COMMAND, + text: 'schedule blocks for tag [TAG]', + branchCount: 1, + arguments: { + TAG: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'shake' + } + } + }, + { + opcode: 'currentCharValue', + blockType: Scratch.BlockType.REPORTER, + text: 'character' + }, + { + opcode: 'currentCharIndex', + blockType: Scratch.BlockType.REPORTER, + text: 'character #' + }, + { + opcode: 'waitForCharsToFinish', + blockType: Scratch.BlockType.COMMAND, + text: 'wait until characters finish rendering' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Character Transforms' + }, + { + opcode: 'setCharPos', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] position to x: [X] y: [Y]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'changeCharPos', + blockType: Scratch.BlockType.COMMAND, + text: 'change character [INDEX] position by x: [X] y: [Y]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'setCharRotation', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] rotation to [DEG] degrees', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + DEG: { + type: Scratch.ArgumentType.ANGLE, + defaultValue: 0 + } + } + }, + { + opcode: 'changeCharRotation', + blockType: Scratch.BlockType.COMMAND, + text: 'change character [INDEX] rotation by [DEG] degrees', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + DEG: { + type: Scratch.ArgumentType.ANGLE, + defaultValue: 0 + } + } + }, + { + opcode: 'setCharOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] opacity to [PCT] %', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'resetCharTransform', + blockType: Scratch.BlockType.COMMAND, + text: 'reset character [INDEX] transform', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'resetAllCharTransforms', + blockType: Scratch.BlockType.COMMAND, + text: 'reset all character transforms' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Character Style' + }, + { + opcode: 'getCharacterStyle', + blockType: Scratch.BlockType.REPORTER, + text: 'style of character [INDEX] as JSON', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'setCharacterStyle', + blockType: Scratch.BlockType.COMMAND, + text: 'set style of character [INDEX] to JSON [STYLE]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + STYLE: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"color":"#9966ff","bold":false,"italic":false,"underline":false,"strike":false,"size":32,"font":"Handwriting","tags":[]}' + } + } + }, + { + opcode: 'resetCharacterStyle', + blockType: Scratch.BlockType.COMMAND, + text: 'reset style of character [INDEX]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Info' + }, + { + opcode: 'getCharX', + blockType: Scratch.BlockType.REPORTER, + text: 'x position of character [INDEX]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'getCharY', + blockType: Scratch.BlockType.REPORTER, + text: 'y position of character [INDEX]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'getCharAt', + blockType: Scratch.BlockType.REPORTER, + text: 'character [INDEX] of the text', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'getCharCount', + blockType: Scratch.BlockType.REPORTER, + text: 'number of characters' + }, + { + opcode: 'getPlainText', + blockType: Scratch.BlockType.REPORTER, + text: 'displayed text (no markup)' + }, + { + opcode: 'getFinalText', + blockType: Scratch.BlockType.REPORTER, + text: 'total final text (no markup)' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Markup Helpers' + }, + { + opcode: 'colorMarkup', + blockType: Scratch.BlockType.REPORTER, + text: 'color [TEXT] as [COLOR]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'rainbow' + }, + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'styleMarkup', + blockType: Scratch.BlockType.REPORTER, + text: 'style [TEXT] as [STYLE]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'important' + }, + STYLE: { + type: Scratch.ArgumentType.STRING, + menu: 'STYLE', + defaultValue: 'bold' + } + } + }, + { + opcode: 'sizeMarkup', + blockType: Scratch.BlockType.REPORTER, + text: 'size [TEXT] as [SIZE]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'BIG' + }, + SIZE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 48 + } + } + }, + { + opcode: 'fontMarkup', + blockType: Scratch.BlockType.REPORTER, + text: 'font [TEXT] as [FONT]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'different' + }, + FONT: { + type: Scratch.ArgumentType.STRING, + menu: 'FONT', + defaultValue: HANDWRITING_ID + } + } + }, + { + opcode: 'tagMarkup', + blockType: Scratch.BlockType.REPORTER, + text: 'tag [TEXT] as [TAG]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'wobble' + }, + TAG: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'shake' + } + } + }, + { + opcode: 'waitMarkup', + blockType: Scratch.BlockType.REPORTER, + text: 'wait [SECONDS] seconds', + arguments: { + SECONDS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0.5 + } + } + } + ], + menus: { + ONOFF: { + items: ['on', 'off'] + }, + ALIGN: { + items: ['left', 'center', 'right', 'justify'] + }, + STYLE: { + items: ['bold', 'italic', 'underline', 'strikethrough'] + }, + TYPING_GROUP: { + items: ['default', 'symbols', 'punctuation', 'numbers'] + }, + FONT: { + items: 'getFontMenuItems', + isTypeable: true + }, + SPRITE: { + items: 'getSpriteMenuItems' + } + } + }; + } + + setText(args, util) { + if (util && util.thread && !ThreadClass) ThreadClass = util.thread.constructor; + const state = getState(util.target); + const frame = util.stackFrame; + + if (frame.irisStages === undefined) { + state.rawText = Scratch.Cast.toString(args.TEXT); + state.finalText = stripMarkup(state.rawText); + state.visible = true; + frame.irisStages = splitWaitStages(state.rawText); + frame.irisStageIndex = 0; + frame.irisToken = ++state.revealToken; + frame.irisWaitUntil = null; + } + + if (state.revealToken !== frame.irisToken) return; + + if (frame.irisWaitUntil !== null) { + if (Date.now() < frame.irisWaitUntil) { + util.yieldTick(); + return; + } + frame.irisWaitUntil = null; + } + + const stage = frame.irisStages[frame.irisStageIndex]; + state.rawText = stage.text; + state.paintDirty = true; + scheduleTextRender(util.target); + + frame.irisStageIndex++; + if (frame.irisStageIndex >= frame.irisStages.length) return; + + frame.irisWaitUntil = Date.now() + stage.waitAfter * 1000; + util.yieldTick(); + } + + typeText(args, util) { + if (util && util.thread && !ThreadClass) ThreadClass = util.thread.constructor; + const state = getState(util.target); + const frame = util.stackFrame; + + if (frame.irisTypingSteps === undefined) { + const text = Scratch.Cast.toString(args.TEXT); + state.rawText = ''; + state.finalText = stripMarkup(text); + state.visible = true; + state.revealToken++; + state.typingControl = null; + frame.irisTypingSteps = splitTypingSteps(text); + frame.irisTypingStepIndex = 0; + frame.irisTypingCharIndex = 0; + frame.irisTypedText = ''; + frame.irisTypingToken = state.revealToken; + frame.irisTypingWaitUntil = null; + } + + if (state.revealToken !== frame.irisTypingToken) return; + + if (state.typingControl === 'stop') { + state.typingControl = null; + state.finalText = stripMarkup(state.rawText); + return; + } + + if (state.typingControl === 'skip') { + while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { + frame.irisTypedText += frame.irisTypingSteps[frame.irisTypingStepIndex++].content; + } + state.rawText = frame.irisTypedText; + state.typingControl = null; + schedulePaint(util.target, state); + flushRenderIfDirty(util.target); + return; + } + + if (frame.irisTypingWaitUntil !== null) { + if (Date.now() < frame.irisTypingWaitUntil) { + util.yieldTick(); + return; + } + frame.irisTypingWaitUntil = null; + } + + while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { + const step = frame.irisTypingSteps[frame.irisTypingStepIndex++]; + frame.irisTypedText += step.content; + if (step.char === null) continue; + + state.rawText = frame.irisTypedText; + schedulePaint(util.target, state); + flushRenderIfDirty(util.target); + startTypingCharacterHat(util.target, step.char, frame.irisTypingCharIndex++); + + const delay = Math.max(0, Number(typingDelayForCharacter(state, step.char)) || 0); + if (delay > 0) { + frame.irisTypingWaitUntil = Date.now() + delay * 1000; + util.yieldTick(); + return; + } + } + + state.rawText = frame.irisTypedText; + schedulePaint(util.target, state); + } + + skipTyping(args, util) { + getState(util.target).typingControl = 'skip'; + } + + stopTyping(args, util) { + getState(util.target).typingControl = 'stop'; + } + + clearText(args, util) { + clearTarget(util.target); + } + + renderNow(args, util) { + const state = getState(util.target); + if (!state.visible) return; + scheduleTextRender(util.target); + } + + waitForCharsToFinish(args, util) { + if (pendingCharacterThreadStarts.has(util.target)) { + if (pendingRenderTargets.has(util.target)) flushRenderIfDirty(util.target); + else flushPendingRenders(); + } + if (!charThreadsStillActive(util.target)) return; + if (renderFlushScheduled) flushPendingRenders(); + util.yieldTick(); + } + + setBaseColor(args, util) { + const state = getState(util.target); + const color = Scratch.Cast.toString(args.COLOR); + if (state.baseStyle.color === color) return; + state.baseStyle.color = color; + schedulePaint(util.target, state); + } + + setBaseSize(args, util) { + const state = getState(util.target); + const size = Math.max(1, Scratch.Cast.toNumber(args.SIZE)); + if (state.baseStyle.size === size) return; + state.baseStyle.size = size; + schedulePaint(util.target, state); + } + + setFont(args, util) { + const state = getState(util.target); + const requested = Scratch.Cast.toString(args.FONT); + const font = requested === RANDOM_ID ? + randomFontOtherThan(state.baseStyle.font) : + requested; + if (state.baseStyle.font === font) return; + state.baseStyle.font = font; + schedulePaint(util.target, state); + } + + getFontMenuItems() { + return buildFontMenuItems(); + } + + getSpriteMenuItems() { + const items = [{ + text: 'myself', + value: '_myself_' + }]; + const names = new Set(); + for (const target of runtime.targets) { + if (target.isStage || target.isOriginal === false) continue; + const name = target.getName(); + if (!names.has(name)) { + names.add(name); + items.push({ + text: name, + value: name + }); + } + } + return items; + } + + exportSettings(args, util) { + return exportTextSettings(getState(util.target)); + } + + applyExportSettings(args, util) { + let settings; + try { + settings = JSON.parse(Scratch.Cast.toString(args.SETTINGS)); + } catch (e) { + return; + } + const sprite = Scratch.Cast.toString(args.SPRITE); + const target = sprite === '_myself_' ? + util.target : + runtime.getSpriteTargetByName(sprite); + if (!target || !applyTextSettings(getState(target), settings)) return; + schedulePaint(target, getState(target)); + } + + setAlign(args, util) { + const state = getState(util.target); + const align = Scratch.Cast.toString(args.ALIGN); + if (state.align === align) return; + state.align = align; + schedulePaint(util.target, state); + } + + setSmoothing(args, util) { + const state = getState(util.target); + const smoothing = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + if (state.smoothing === smoothing) return; + state.smoothing = smoothing; + schedulePaint(util.target, state); + } + + setTypingSpeed(args, util) { + const state = getState(util.target); + const group = Scratch.Cast.toString(args.GROUP); + if (!_hasOwn.call(state.typingSpeeds, group)) return; + state.typingSpeeds[group] = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); + } + + setCustomTypingSpeed(args, util) { + const state = getState(util.target); + const delay = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); + for (const char of Array.from(Scratch.Cast.toString(args.CHARS))) { + state.customTypingSpeeds[char] = delay; + } + } + + setMaxWidth(args, util) { + const state = getState(util.target); + const width = Scratch.Cast.toNumber(args.WIDTH); + if (state.maxWidth === width) return; + state.maxWidth = width; + schedulePaint(util.target, state); + } + + setMaxHeight(args, util) { + const state = getState(util.target); + const height = Scratch.Cast.toNumber(args.HEIGHT); + if (state.maxHeight === height) return; + state.maxHeight = height; + schedulePaint(util.target, state); + } + + setMaxDimensions(args, util) { + const state = getState(util.target); + const width = Scratch.Cast.toNumber(args.WIDTH); + const height = Scratch.Cast.toNumber(args.HEIGHT); + if (state.maxWidth === width && state.maxHeight === height) return; + state.maxWidth = width; + state.maxHeight = height; + schedulePaint(util.target, state); + } + + clearMaxDimensions(args, util) { + const state = getState(util.target); + if (state.maxWidth == null && state.maxHeight == null) return; + state.maxWidth = null; + state.maxHeight = null; + schedulePaint(util.target, state); + } + + setLetterSpacing(args, util) { + const state = getState(util.target); + const letterSpacing = Scratch.Cast.toNumber(args.SPACE); + if (state.letterSpacing === letterSpacing) return; + state.letterSpacing = letterSpacing; + schedulePaint(util.target, state); + } + + setTextShadowEnabled(args, util) { + const state = getState(util.target); + state.textShadow.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextShadowColor(args, util) { + const state = getState(util.target); + state.textShadow.color = Scratch.Cast.toString(args.COLOR); + schedulePaint(util.target, state); + } + + setTextShadowOpacity(args, util) { + const state = getState(util.target); + state.textShadow.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextShadowBlur(args, util) { + const state = getState(util.target); + state.textShadow.blur = Math.max(0, Scratch.Cast.toNumber(args.BLUR)); + schedulePaint(util.target, state); + } + + setTextShadowOffset(args, util) { + const state = getState(util.target); + state.textShadow.offsetX = Scratch.Cast.toNumber(args.X); + state.textShadow.offsetY = Scratch.Cast.toNumber(args.Y); + schedulePaint(util.target, state); + } + + setTextBackgroundEnabled(args, util) { + const state = getState(util.target); + state.textBackground.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextBackgroundColor(args, util) { + const state = getState(util.target); + state.textBackground.color = Scratch.Cast.toString(args.COLOR); + schedulePaint(util.target, state); + } + + setTextBackgroundOpacity(args, util) { + const state = getState(util.target); + state.textBackground.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextBackgroundRadius(args, util) { + const state = getState(util.target); + state.textBackground.radius = Math.max(0, Scratch.Cast.toNumber(args.RADIUS)); + schedulePaint(util.target, state); + } + + setTextBackgroundPadding(args, util) { + const state = getState(util.target); + state.textBackground.padding = Math.max(0, Scratch.Cast.toNumber(args.PADDING)); + schedulePaint(util.target, state); + } + + setTextBorderEnabled(args, util) { + const state = getState(util.target); + state.textBorder.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextBorderColor(args, util) { + const state = getState(util.target); + state.textBorder.color = Scratch.Cast.toString(args.COLOR); + schedulePaint(util.target, state); + } + + setTextBorderOpacity(args, util) { + const state = getState(util.target); + state.textBorder.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextBorderSize(args, util) { + const state = getState(util.target); + state.textBorder.size = Math.max(0, Scratch.Cast.toNumber(args.SIZE)); + schedulePaint(util.target, state); + } + + scheduleTagBlocks(args, util) { + const name = util.target.id; + const blocks = util.thread.target.blocks; + const scheduleBlock = util.thread.peekStack(); + const branch = blocks.getBranch(scheduleBlock, 1); + const tag = Scratch.Cast.toString(args.TAG).toLowerCase(); + + if (charScripts[name] === undefined) { + charScripts[name] = Object.create(null); + } + + if (!branch) { + delete charScripts[name][tag]; + return; + } + + const snapshot = createBlocksSnapshot(blocks, branch, runtime); + const ThreadClass = util.thread ? util.thread.constructor : null; + let compiledResult = null; + + if (ThreadClass) { + const tempThread = new ThreadClass(branch); + tempThread.target = util.target; + tempThread.blockContainer = snapshot; + tempThread.pushStack(branch); + if (typeof tempThread.tryCompile === 'function') { + tempThread.tryCompile(); + if (tempThread.isCompiled) { + compiledResult = snapshot.getCachedCompileResult(branch); + } + } + } + + const newEntry = { + stack: branch, + blocks: snapshot, + target: util.target, + tag, + compiledResult: compiledResult ? (compiledResult.value || compiledResult) : null, + perChar: stackUsesCharReporter(snapshot, branch) + }; + + charScripts[name][tag] = newEntry; + } + + currentCharValue(args, util) { + const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; + return cur ? cur.char : ''; + } + + currentCharIndex(args, util) { + const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; + return cur ? cur.index + 1 : 0; + } + + setCharPos(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const x = Scratch.Cast.toNumber(args.X); + const y = Scratch.Cast.toNumber(args.Y); + const exists = !!state.charOverrides[idx]; + const o = getCharOverride(state, idx); + if (exists && o.x === x && o.y === y) return; + o.x = x; + o.y = y; + schedulePaint(util.target, state); + } + + changeCharPos(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const x = Scratch.Cast.toNumber(args.X); + const y = Scratch.Cast.toNumber(args.Y); + const exists = !!state.charOverrides[idx]; + const o = getCharOverride(state, idx); + if (exists && x === 0 && y === 0) return; + o.x += x; + o.y += y; + schedulePaint(util.target, state); + } + + setCharRotation(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const rotation = Scratch.Cast.toNumber(args.DEG); + const exists = !!state.charOverrides[idx]; + const o = getCharOverride(state, idx); + if (exists && o.rotation === rotation) return; + o.rotation = rotation; + schedulePaint(util.target, state); + } + + changeCharRotation(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const rotation = Scratch.Cast.toNumber(args.DEG); + const exists = !!state.charOverrides[idx]; + const o = getCharOverride(state, idx); + if (exists && rotation === 0) return; + o.rotation += rotation; + schedulePaint(util.target, state); + } + + setCharOpacity(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))) / 100; + const exists = !!state.charOverrides[idx]; + const o = getCharOverride(state, idx); + if (exists && o.opacity === opacity) return; + o.opacity = opacity; + schedulePaint(util.target, state); + } + + resetCharTransform(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + if (state.charOverrides[idx]) { + delete state.charOverrides[idx]; + state.charOverridesVersion++; + schedulePaint(util.target, state); + } + } + + resetAllCharTransforms(args, util) { + const state = getState(util.target); + if (Object.keys(state.charOverrides).length) { + state.charOverrides = {}; + state.charOverridesVersion++; + schedulePaint(util.target, state); + } + } + + getCharacterStyle(args, util) { + const state = getState(util.target); + flushRenderIfDirty(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const box = state.charBoxes[idx]; + return box && box.style ? JSON.stringify(box.style) : ''; + } + + setCharacterStyle(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + flushRenderIfDirty(util.target); + const box = state.charBoxes[idx]; + if (!box || !box.style) return; + + let input; + try { + input = JSON.parse(Scratch.Cast.toString(args.STYLE)); + } catch (e) { + return; + } + const style = characterStyleFromJSON(input, box.style); + if (!style) return; + state.charStyleOverrides[idx] = style; + state.charStyleOverridesVersion++; + state.shapeKey = null; + state.layout = null; + state.paintFingerprint = null; + schedulePaint(util.target, state); + } + + resetCharacterStyle(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + if (!state.charStyleOverrides[idx]) return; + delete state.charStyleOverrides[idx]; + state.charStyleOverridesVersion++; + state.shapeKey = null; + state.layout = null; + state.paintFingerprint = null; + schedulePaint(util.target, state); + } + + getCharX(args, util) { + const state = getState(util.target); + flushRenderIfDirty(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const box = state.charBoxes[idx]; + return box ? box.x : 0; + } + + getCharY(args, util) { + const state = getState(util.target); + flushRenderIfDirty(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const box = state.charBoxes[idx]; + return box ? box.y : 0; + } + + getCharAt(args, util) { + const state = getState(util.target); + const plain = stripMarkup(state.rawText); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + return plain[idx] || ''; + } + + getCharCount(args, util) { + const state = getState(util.target); + return stripMarkup(state.rawText).length; + } + + getPlainText(args, util) { + const state = getState(util.target); + return stripMarkup(state.rawText); + } + + getFinalText(args, util) { + const state = getState(util.target); + return state.finalText; + } + + colorMarkup(args) { + const text = Scratch.Cast.toString(args.TEXT); + const color = Scratch.Cast.toString(args.COLOR); + return `[color=${color}]${text}[/color]`; + } + + styleMarkup(args) { + const text = Scratch.Cast.toString(args.TEXT); + const map = { + bold: 'b', + italic: 'i', + underline: 'u', + strikethrough: 's' + }; + const tag = map[args.STYLE] || 'b'; + return `[${tag}]${text}[/${tag}]`; + } + + sizeMarkup(args) { + const text = Scratch.Cast.toString(args.TEXT); + const size = Scratch.Cast.toNumber(args.SIZE); + return `[size=${size}]${text}[/size]`; + } + + fontMarkup(args) { + const text = Scratch.Cast.toString(args.TEXT); + const requested = Scratch.Cast.toString(args.FONT); + const font = requested === RANDOM_ID ? randomFontOtherThan(RANDOM_ID) : requested; + return `[font=${font}]${text}[/font]`; + } + + tagMarkup(args) { + const text = Scratch.Cast.toString(args.TEXT); + const tag = Scratch.Cast.toString(args.TAG).toLowerCase().replace(/[^a-z0-9_-]/g, ''); + if (!tag) return text; + return `[${tag}]${text}[/${tag}]`; + } + + waitMarkup(args) { + const seconds = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); + return `[wait=${seconds}]`; + } + } + + Scratch.extensions.register(new IrisText()); +})(Scratch); \ No newline at end of file From d0f314e09417b8b2dc9fe57f767418c23fa3f331 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 18 Aug 2026 01:13:58 -0300 Subject: [PATCH 02/21] add iris text + update all my extensions with my github account --- src/lib/extensions.js | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/lib/extensions.js b/src/lib/extensions.js index 84d1c063a..051d4d3d5 100644 --- a/src/lib/extensions.js +++ b/src/lib/extensions.js @@ -33,7 +33,8 @@ export default [ description: "A storage extension with many more features. Featuring server storage (with a fast server), local storage and more!", code: "Gen1x/storage_plus.js", banner: "Gen1x/storage_plus.avif", - creator: "G1nX", + isGithub: true, + creator: "Gen1x", }, { name: "MotionSprite", @@ -43,6 +44,14 @@ export default [ isGitHub: true, creator: "TheShovel", }, + { + name: "Iris Text", + description: "A heavily-expanded text engine loosely based on Animated Text! Adds support for rich text, per-character control, and more!", + code: "Gen1x/iris-text.js", + banner: "Gen1x/iris-text.avif", + isGitHub: true, + creator: "Gen1xLol", + }, { name: "Boxed Physics", description: "Implements the Box2D physics engine into PenguinMod, adding joints, springs, etc. This is different from TurboWarp's implementation.", @@ -55,7 +64,8 @@ export default [ description: "A fast, powerful and easy-to-use lighting engine powered by WebGL!", code: "Gen1x/lighting.js", banner: "Gen1x/lighting.avif", - creator: "G1nX", + isGitHub: true, + creator: "Gen1xLol", tags: ["new"], }, { @@ -81,7 +91,8 @@ export default [ description: "An extension designed to let you sync anything in your project to a musical beat, with incredible precision.", code: "Gen1x/beat_sync.js", banner: "Gen1x/beat_sync.avif", - creator: "G1nX", + isGitHub: true, + creator: "Gen1xLol", }, { name: "Object", @@ -341,10 +352,11 @@ export default [ }, { name: "Random Utilities", - description: "Many blocks related to generating random values, including seed-based number generation, true number generation, UUID's, random strings, etc.", + description: "Many blocks related to generating random values. Includes seed-based number generation, true number generation, UUIDs, random strings, etc.", code: "Gen1x/random_utils.js", banner: "Gen1x/randomutils.avif", - creator: "G1nX", + isGitHub: true, + creator: "Gen1xLol", }, { name: "Toast Notifications", @@ -529,15 +541,16 @@ export default [ description: "A powerful extension that lets you manage a chessboard, customize it, use Stockfish 17 (via API), a local chess engine (Lozza) and much, MUCH more!", code: "Gen1x/chess-ext.js", banner: "Gen1x/chess-ext.avif", - creator: "G1nX", - isGitHub: false, + isGitHub: true, + creator: "Gen1xLol", }, { name: "CATS", description: "Blocks related to cats.", code: "Gen1x/CATS.js", banner: "Gen1x/cats.avif", - creator: "G1nX", + isGitHub: true, + creator: "Gen1xLol", }, { name: "Free Servers", From b33a3dfe947feccc17a0fa6b8c4661e13125d0f5 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 18 Aug 2026 01:14:47 -0300 Subject: [PATCH 03/21] add the new tag --- src/lib/extensions.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/extensions.js b/src/lib/extensions.js index 051d4d3d5..e4f1ab3a5 100644 --- a/src/lib/extensions.js +++ b/src/lib/extensions.js @@ -51,6 +51,7 @@ export default [ banner: "Gen1x/iris-text.avif", isGitHub: true, creator: "Gen1xLol", + tags: ["new"], }, { name: "Boxed Physics", From a6becd33b187e9b13cac0b91f24644256444c46f Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 18 Aug 2026 01:15:45 -0300 Subject: [PATCH 04/21] in all honesty, i was lazy while making the thumbnail. --- static/images/Gen1x/iris-text.avif | Bin 0 -> 5831 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 static/images/Gen1x/iris-text.avif diff --git a/static/images/Gen1x/iris-text.avif b/static/images/Gen1x/iris-text.avif new file mode 100644 index 0000000000000000000000000000000000000000..7346c8755e7baccbc27bde7add31b5fdafe1f753 GIT binary patch literal 5831 zcmbtYc{r5s*B_C+ghIAN46@AF_a#eYDQl518z#(*S?o);EG1>lmVM6_SwbjVWmi$k zF407ws6x*{FG zu1E+8Q$So1cnDcfdd@n*U9g8s004u8;s06xBadT{c-I3!LHgB2x}afXq^$v<1dufV ziqJy|05G)xj*vCXy8mbZ*GMd~=FvZzqaq{-MIuoLfgS9gA_2sKA;}-a9Rwy@{G+5G zyVUVK%tQ1SlY(sLigZN)AQ+4b5(dE|(I`nQ+~K^Zs|(p0V2Q+FF(j#g#K6!b$p~== zYXu%;xGvE0suHl`a9A``Z<9?dLqdhN?KZ4l3+t(T>m*D3IGLV;1O~j!bmLQ zFm3?gI1G-1k%A-bl6DRe`9w(Q)FoFq1kVDXr$2quaW}n%;uv*^RQVb0TY#)c;VY}p zm(By*a~-JUY>T1yd|N0(obyV|%GU?75?2|N=>3H|5t$*vrj6JrhD6>G!%c}2jmK$+?7$THdp4*wv zq;9qC+nbS=*W5#&+^~$h5^c<3`||sdPXqkn&K`1o1fhi`s$~0grW%LYv*I=0^c|Ng zoI|@~P9>aVN(qkIWI#EX$pq1|)6?9{vS^;-hltLmZn4+B@^ z3#>W}zfckUDQ^S}(Z4hB-5BEeI^UN^nH~ERwZ53an&vN(Cu;lIkjI?GNS#%}0h@K* z_w0GjWoLbkFNr@dqJfOzwvTJ&xL}lFk>@O(KKL=}DrZElbmhNo&VK?-2t*tMx83KA zMixCwgVV08Ivc$kDUG=Kc$@!?~YvT5JuGvGc{(|kvO>5(o;Q^$R zg;ALby6nw0Iq>edySdCse!PTfI~!HQ`tpc%&SK-fMtgHavlhqWgeX_j(Z29K`JV%! zy+zD@-XW)}-#p9scCpud|N4>ccy@-&50?aVv>w#1Zs3d4=u)&N(vdRQlBlN z8@eVWN7`~B#L1asNJ$DfEZsJy^nPXxDt#>U<%pi>@iWa5H7P8&>CoN#3DiT(#(R}T zZaim{7kQ<*r}`Hz2MwB(D{vI`WbMf_L~2C@b#o=Ecd_YkrqBNo`cK3!zTo41iGc9O!>MyU0kq@5I6aBf1xz$5U+=a>TPYXfcP0?)cHNJTQ? zndT>9W;^%iR1|JT5)#OleDKq+>_uTo zL8U;?&VHhxF}9YbHh!Uz+wi06r6~WbrfQ>OUx&n!W<~T}JSzPTGIe9ur)d;bm?Eaj`!yd+qfY zHSxN8U>a+*Lk}uy)BvV%d#Mf!#1uSmj1MVcs8L|yU%y*WSdlk!aywZ0BHBXm=-@3E zr6cK&>dwWM2j;-1OO^Song^MpnoZ{=M1OiP&n?{$yi2hqA90K8BDh96B+^>F=pMm1 zi77abN^Yy4gP@0eRZTt97e)K1!BXu-t9m`l6RaFYT+K{3Stq}vWp}TklFREzcKYBI znR_W>i##u%&cz!M32yoE_yPU`Z{a2=Wq#zgDJ!OFKp|C`QwcTLUa_zg*Vxh$`pn{7Is-Tl)QE1G?E!Yenhrk3%xUHfZ@@o#>rv zFY2{^Ps=Yyv&Lhdp|E;SFD&z^!CASmkF<7KO`q5CCzT456Lz15FWzK`g!U0RvG)nD z>^S;|MYiHbCRBpU_88` z=}ot(wE0g^T}vOCZv%}k82jDk9@6D8aL(c(?CF&7D_TZ|z6(DE8}v?tqpe;t2iyo#dCuC!-OfDh(PtjK5n6;I)AXe` zv;m6Oa_$*DlQMVj`%m0F=)xUou$AJM7Ghr}@ecKXN(3${hL1JvwOvR#y>6cka+6-r zo(t2+)R#N|EZw$_T6=hB_p)a(@VwRvt;W?9_{MWs9h-HLrbnyj~SI^#o$fUJ}#ao&EeM+8&ie91#)#u8Yr}<@VMIXDs`r~-1{G1C0tAs-2R0i(| zy5!Y;U;{6LOP{j@TP)og)SKZd{CBKVA9}5~Qpio^J#G<;GmRtjw4! zph+~s+=9`zP(g0 zVDS~JlgLcd>6hl@uxt5s$1>bo?d^^vW!@XOG93sy%k4Gs&LlhNM^*o{8Uv#TRa@DI zqP@K@_9a3eMm-vFKch_Odqe}~5K}gh#knQ*hD92@v(6>kF{5$|3vV2&)>`kFB`yf; zlo3-SJ;RtSm&{AX#R8#^Jj{7`(_8cbw78n&MDLHFk<#5E0o8>L&r=~s*$_v$b;T2V zk!ke_lW}`rB(pL=Hx@L*q(hai&-&^eouasPiOyOf44U>_aWnbmF`WL7mFB=7Ap@5b zQ`;@R%Vnm-J4XQOy7#|FyuS0T7{*vItrqLV05KLWF}xA!V)^!{*q6ARyJ_F8TJ_lQ zoJ*+Cri+ayDjBt_xf_qO-LtuadBKiF_RTy3NP}a&E!ZL=8%eQMSg7*zK z?)43Ig5ktc%114uRqUAPKAByEYfo&qyE-IZYZTwH+paUkQ$j6|Vi{8IUlMr85zXko z@itYiEvSY&wy>4^w2ST)7EqLsL5*h{D90xeWWQmJ$X*HhLL*z6USD#ZV!Kc!;bFly zF{#sC92eK^NyX9)f%6OBj=MYGO;q1z(hbxQ*2Tn4=Bs?m7UW2?QVFVkABs39+Qr3^ zq#9n`l>1KnuA*i)VkuTp+w) z!dKcv)*L)?*Cv;DTI7vpX}2_@fqQvtL0BnFZ|2K*q{Cxfu?e}1FqhQU_Z#JzR&F8O zME*j0v&!~;g#d-As~eB@5_o4cweNKeG416I^$xIO^2*B2)w@O4MfyZP#9yS?U8ZkN z+v)7-p=V(0Q*>abfl>QY_2L}Hg|cnw zOlv!Qbh@Vp)|jv~umS0-=PT8rY2}W`i*SHR*vU{rwKLydaM0_Oy>cLr`V7szS|DiNZw5@{4ydIhE@|GgsR0hJG;B z-)nOeseI!1{2O8FO9eQ(YC1M(`APJO_ly8BZw*1cKX*rl){CuBvVYpZqky_jKg_%H z=Y*R_YgXKYH&^3^kXx@_N%YU!9J?y6Kj#8g8cXQ6_yp&FD91j=u|#!zpg~Um$8k`} z)_NkpCN651Q>ch!{kr6Yf+~%o%~_gCZP}C{^HceG`xm z30DPPl{EqxVYCoVNIh>X!pz&)9Omr|Q-lN6)#z0`l{{TB)A7XX!_~kDn3SxvB1{~CfXRx>z!hY~6=k6?ad{X71OmfkzzPV+Z!d=? zf4k9zlDx=D$x6w|DuQLe@`|zwpx*`un}74o(FB+i`JC0He$)Qd9dK31BQ+%#xa}BN-hwTqbkr-9FA~+5M1y;HGM1+$0yDQ{x8}5a-bslhwuMKE|@>sjoc9A z?mTD((grCJ>0gPEkdh_=?}WyxT|}TDShSV7isTg{?QtI^J}b literal 0 HcmV?d00001 From 42ec90373481b348062161eba44343ef4eef3016 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 18 Aug 2026 01:34:20 -0300 Subject: [PATCH 05/21] thank you steve for a portion of these changes --- static/extensions/Gen1x/iris-text.js | 2661 +++++++++++++------------- 1 file changed, 1368 insertions(+), 1293 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 95dc61092..27040d832 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -165,7 +165,7 @@ Enjoy!! :D const TAG_RE = /\[(\/?)([a-z0-9_-]+)(?:=([^\]]+))?\]/gi; const TAG_RE_STRIP = /\[(\/?)([a-z0-9_-]+)(?:=([^\]]+))?\]/gi; - function parseRichText(text, base) { + function parseRichText(text, base) { const stack = [Object.assign({ customTags: [] }, base)]; @@ -245,55 +245,55 @@ Enjoy!! :D } i = match.index + match[0].length; } - return out; - } - - function characterStyleSnapshot(style) { - return { - color: style.color, - bold: style.bold, - italic: style.italic, - underline: style.underline, - strike: style.strike, - size: style.size, - font: style.font, - tags: Array.isArray(style.tags) ? style.tags.slice() : [] - }; - } - - function applyCharacterStyleOverrides(chars, overrides) { - for (const index in overrides) { - const char = chars[index]; - const override = overrides[index]; - if (!char || !override) continue; - char.color = override.color; - char.bold = override.bold; - char.italic = override.italic; - char.underline = override.underline; - char.strike = override.strike; - char.size = override.size; - char.font = override.font; - char.tags = override.tags.slice(); - } - } - - function characterStyleFromJSON(value, base) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const style = characterStyleSnapshot(base); - if (typeof value.color === 'string') style.color = value.color; - if (typeof value.bold === 'boolean') style.bold = value.bold; - if (typeof value.italic === 'boolean') style.italic = value.italic; - if (typeof value.underline === 'boolean') style.underline = value.underline; - if (typeof value.strike === 'boolean') style.strike = value.strike; - if (Number.isFinite(value.size)) style.size = Math.max(1, value.size); - if (typeof value.font === 'string') style.font = value.font; - if (Array.isArray(value.tags) && value.tags.every(tag => typeof tag === 'string')) { - style.tags = value.tags.slice(); - } - return style; - } - - function stripMarkup(text) { + return out; + } + + function characterStyleSnapshot(style) { + return { + color: style.color, + bold: style.bold, + italic: style.italic, + underline: style.underline, + strike: style.strike, + size: style.size, + font: style.font, + tags: Array.isArray(style.tags) ? style.tags.slice() : [] + }; + } + + function applyCharacterStyleOverrides(chars, overrides) { + for (const index in overrides) { + const char = chars[index]; + const override = overrides[index]; + if (!char || !override) continue; + char.color = override.color; + char.bold = override.bold; + char.italic = override.italic; + char.underline = override.underline; + char.strike = override.strike; + char.size = override.size; + char.font = override.font; + char.tags = override.tags.slice(); + } + } + + function characterStyleFromJSON(value, base) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const style = characterStyleSnapshot(base); + if (typeof value.color === 'string') style.color = value.color; + if (typeof value.bold === 'boolean') style.bold = value.bold; + if (typeof value.italic === 'boolean') style.italic = value.italic; + if (typeof value.underline === 'boolean') style.underline = value.underline; + if (typeof value.strike === 'boolean') style.strike = value.strike; + if (Number.isFinite(value.size)) style.size = Math.max(1, value.size); + if (typeof value.font === 'string') style.font = value.font; + if (Array.isArray(value.tags) && value.tags.every(tag => typeof tag === 'string')) { + style.tags = value.tags.slice(); + } + return style; + } + + function stripMarkup(text) { TAG_RE_STRIP.lastIndex = 0; return text.replace(TAG_RE_STRIP, ''); } @@ -335,52 +335,53 @@ Enjoy!! :D return stages; } - function defaultState() { - return { + function defaultState() { + return { rawText: '', finalText: '', visible: false, baseStyle: defaultBaseStyle(), - align: 'center', - smoothing: true, - letterSpacing: 0, - lineSpacing: 1.2, - typingSpeeds: { - default: 0.05, - symbols: 0.05, - punctuation: 0.15, - numbers: 0.05 - }, - customTypingSpeeds: {}, - maxWidth: null, - maxHeight: null, - textShadow: { - enabled: false, - color: '#000000', - opacity: 50, - blur: 8, - offsetX: 4, - offsetY: 4 - }, - textBackground: { - enabled: false, - color: '#000000', - opacity: 50, - radius: 8, - padding: 8 - }, - textBorder: { - enabled: false, - color: '#000000', - opacity: 100, - size: 2 - }, - charOverrides: {}, - charOverridesVersion: 0, - charStyleOverrides: {}, - charStyleOverridesVersion: 0, - typingControl: null, - skinId: null, + align: 'center', + smoothing: true, + letterSpacing: 0, + lineSpacing: 1.2, + typingSpeeds: { + default: 0.05, + symbols: 0.05, + punctuation: 0.15, + numbers: 0.05 + }, + customTypingSpeeds: {}, + maxWidth: null, + maxHeight: null, + textShadow: { + enabled: false, + color: '#000000', + opacity: 50, + blur: 8, + offsetX: 4, + offsetY: 4 + }, + textBackground: { + enabled: false, + color: '#000000', + opacity: 50, + radius: 8, + padding: 8 + }, + textBorder: { + enabled: false, + color: '#000000', + opacity: 100, + size: 2 + }, + charOverrides: {}, + charOverridesVersion: 0, + resetCharTransformsOnText: true, + charStyleOverrides: {}, + charStyleOverridesVersion: 0, + typingControl: null, + skinId: null, drawableWidth: 0, drawableHeight: 0, charBoxes: [], @@ -408,162 +409,165 @@ Enjoy!! :D base.strike ? 1 : 0, base.size, base.font, - state.letterSpacing, - state.lineSpacing, - state.maxWidth || 0, - state.maxHeight || 0, - state.charStyleOverridesVersion - ].join('\u0001'); + state.letterSpacing, + state.lineSpacing, + state.maxWidth || 0, + state.maxHeight || 0, + state.charStyleOverridesVersion + ].join('\u0001'); + } + + function getState(target) { + let state = target.getCustomState(STATE_KEY); + if (!state) { + state = defaultState(); + target.setCustomState(STATE_KEY, state); + } else { + const defaults = defaultState(); + state.textShadow = Object.assign(defaults.textShadow, state.textShadow || {}); + state.textBackground = Object.assign(defaults.textBackground, state.textBackground || {}); + state.textBorder = Object.assign(defaults.textBorder, state.textBorder || {}); + state.typingSpeeds = Object.assign(defaults.typingSpeeds, state.typingSpeeds || {}); + state.customTypingSpeeds = Object.assign({}, state.customTypingSpeeds || {}); + if (typeof state.smoothing !== 'boolean') state.smoothing = defaults.smoothing; + state.charStyleOverrides = Object.assign({}, state.charStyleOverrides || {}); + if (typeof state.resetCharTransformsOnText !== 'boolean') state.resetCharTransformsOnText = defaults.resetCharTransformsOnText; + if (typeof state.charStyleOverridesVersion !== 'number') state.charStyleOverridesVersion = 0; + if (state.typingControl !== 'skip' && state.typingControl !== 'stop') state.typingControl = null; + } + return state; + } + + const TYPING_PUNCTUATION = '.,!?;:\'"…'; + const TYPING_SYMBOLS = '@#$%^&*+=<>/\\|~`_-()[]{}'; + + function typingGroupForCharacter(char) { + if (/^[0-9]$/.test(char)) return 'numbers'; + if (TYPING_PUNCTUATION.includes(char)) return 'punctuation'; + if (TYPING_SYMBOLS.includes(char)) return 'symbols'; + return 'default'; + } + + function typingDelayForCharacter(state, char) { + if (_hasOwn.call(state.customTypingSpeeds, char)) return state.customTypingSpeeds[char]; + return state.typingSpeeds[typingGroupForCharacter(char)]; + } + + function splitTypingSteps(text) { + const steps = []; + TAG_RE.lastIndex = 0; + let lastIndex = 0; + let match; + + const addCharacters = value => { + for (const char of Array.from(value)) { + steps.push({ + content: char, + char + }); + } + }; + + while ((match = TAG_RE.exec(text)) !== null) { + addCharacters(text.slice(lastIndex, match.index)); + steps.push({ + content: match[0], + char: null + }); + lastIndex = match.index + match[0].length; + } + addCharacters(text.slice(lastIndex)); + return steps; + } + + function exportTextSettings(state) { + return JSON.stringify({ + version: 1, + baseStyle: { + color: state.baseStyle.color, + bold: state.baseStyle.bold, + italic: state.baseStyle.italic, + underline: state.baseStyle.underline, + strike: state.baseStyle.strike, + size: state.baseStyle.size, + font: state.baseStyle.font + }, + align: state.align, + smoothing: state.smoothing, + resetCharTransformsOnText: state.resetCharTransformsOnText, + letterSpacing: state.letterSpacing, + lineSpacing: state.lineSpacing, + typingSpeeds: Object.assign({}, state.typingSpeeds), + customTypingSpeeds: Object.assign({}, state.customTypingSpeeds), + maxWidth: state.maxWidth, + maxHeight: state.maxHeight, + textShadow: Object.assign({}, state.textShadow), + textBackground: Object.assign({}, state.textBackground), + textBorder: Object.assign({}, state.textBorder) + }); } - function getState(target) { - let state = target.getCustomState(STATE_KEY); - if (!state) { - state = defaultState(); - target.setCustomState(STATE_KEY, state); - } else { - const defaults = defaultState(); - state.textShadow = Object.assign(defaults.textShadow, state.textShadow || {}); - state.textBackground = Object.assign(defaults.textBackground, state.textBackground || {}); - state.textBorder = Object.assign(defaults.textBorder, state.textBorder || {}); - state.typingSpeeds = Object.assign(defaults.typingSpeeds, state.typingSpeeds || {}); - state.customTypingSpeeds = Object.assign({}, state.customTypingSpeeds || {}); - if (typeof state.smoothing !== 'boolean') state.smoothing = defaults.smoothing; - state.charStyleOverrides = Object.assign({}, state.charStyleOverrides || {}); - if (typeof state.charStyleOverridesVersion !== 'number') state.charStyleOverridesVersion = 0; - if (state.typingControl !== 'skip' && state.typingControl !== 'stop') state.typingControl = null; - } - return state; - } - - const TYPING_PUNCTUATION = '.,!?;:\'"…'; - const TYPING_SYMBOLS = '@#$%^&*+=<>/\\|~`_-()[]{}'; - - function typingGroupForCharacter(char) { - if (/^[0-9]$/.test(char)) return 'numbers'; - if (TYPING_PUNCTUATION.includes(char)) return 'punctuation'; - if (TYPING_SYMBOLS.includes(char)) return 'symbols'; - return 'default'; - } - - function typingDelayForCharacter(state, char) { - if (_hasOwn.call(state.customTypingSpeeds, char)) return state.customTypingSpeeds[char]; - return state.typingSpeeds[typingGroupForCharacter(char)]; - } - - function splitTypingSteps(text) { - const steps = []; - TAG_RE.lastIndex = 0; - let lastIndex = 0; - let match; - - const addCharacters = value => { - for (const char of Array.from(value)) { - steps.push({ - content: char, - char - }); - } - }; - - while ((match = TAG_RE.exec(text)) !== null) { - addCharacters(text.slice(lastIndex, match.index)); - steps.push({ - content: match[0], - char: null - }); - lastIndex = match.index + match[0].length; - } - addCharacters(text.slice(lastIndex)); - return steps; - } - - function exportTextSettings(state) { - return JSON.stringify({ - version: 1, - baseStyle: { - color: state.baseStyle.color, - bold: state.baseStyle.bold, - italic: state.baseStyle.italic, - underline: state.baseStyle.underline, - strike: state.baseStyle.strike, - size: state.baseStyle.size, - font: state.baseStyle.font - }, - align: state.align, - smoothing: state.smoothing, - letterSpacing: state.letterSpacing, - lineSpacing: state.lineSpacing, - typingSpeeds: Object.assign({}, state.typingSpeeds), - customTypingSpeeds: Object.assign({}, state.customTypingSpeeds), - maxWidth: state.maxWidth, - maxHeight: state.maxHeight, - textShadow: Object.assign({}, state.textShadow), - textBackground: Object.assign({}, state.textBackground), - textBorder: Object.assign({}, state.textBorder) - }); - } - - function applyTextSettings(state, settings) { - if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false; - - const base = settings.baseStyle; - if (base && typeof base === 'object' && !Array.isArray(base)) { - if (typeof base.color === 'string') state.baseStyle.color = base.color; - if (typeof base.bold === 'boolean') state.baseStyle.bold = base.bold; - if (typeof base.italic === 'boolean') state.baseStyle.italic = base.italic; - if (typeof base.underline === 'boolean') state.baseStyle.underline = base.underline; - if (typeof base.strike === 'boolean') state.baseStyle.strike = base.strike; - if (Number.isFinite(base.size)) state.baseStyle.size = Math.max(1, base.size); - if (typeof base.font === 'string') state.baseStyle.font = base.font; - } - if (settings.align === 'left' || settings.align === 'center' || settings.align === 'right' || settings.align === 'justify') { - state.align = settings.align; - } - if (typeof settings.smoothing === 'boolean') state.smoothing = settings.smoothing; - if (Number.isFinite(settings.letterSpacing)) state.letterSpacing = settings.letterSpacing; - if (Number.isFinite(settings.lineSpacing)) state.lineSpacing = Math.max(0, settings.lineSpacing); - if (settings.typingSpeeds && typeof settings.typingSpeeds === 'object' && !Array.isArray(settings.typingSpeeds)) { - for (const group of ['default', 'symbols', 'punctuation', 'numbers']) { - if (Number.isFinite(settings.typingSpeeds[group])) { - state.typingSpeeds[group] = Math.max(0, settings.typingSpeeds[group]); - } - } - } - if (settings.customTypingSpeeds && typeof settings.customTypingSpeeds === 'object' && !Array.isArray(settings.customTypingSpeeds)) { - state.customTypingSpeeds = {}; - for (const char in settings.customTypingSpeeds) { - if (Number.isFinite(settings.customTypingSpeeds[char])) { - state.customTypingSpeeds[char] = Math.max(0, settings.customTypingSpeeds[char]); - } - } - } - if (settings.maxWidth === null || Number.isFinite(settings.maxWidth)) state.maxWidth = settings.maxWidth; - if (settings.maxHeight === null || Number.isFinite(settings.maxHeight)) state.maxHeight = settings.maxHeight; - - const copySettings = (source, target, keys) => { - if (!source || typeof source !== 'object' || Array.isArray(source)) return; - for (const key of keys) { - if (typeof target[key] === 'boolean' && typeof source[key] === 'boolean') { - target[key] = source[key]; - } else if (typeof target[key] === 'number' && Number.isFinite(source[key])) { - target[key] = source[key]; - } else if (typeof target[key] === 'string' && typeof source[key] === 'string') { - target[key] = source[key]; - } - } - }; - - copySettings(settings.textShadow, state.textShadow, ['enabled', 'color', 'opacity', 'blur', 'offsetX', 'offsetY']); - copySettings(settings.textBackground, state.textBackground, ['enabled', 'color', 'opacity', 'radius', 'padding']); - copySettings(settings.textBorder, state.textBorder, ['enabled', 'color', 'opacity', 'size']); - state.shapeKey = null; - state.layoutKey = null; - state.layout = null; - state.paintDirty = true; - state.paintFingerprint = null; - return true; - } + function applyTextSettings(state, settings) { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false; + + const base = settings.baseStyle; + if (base && typeof base === 'object' && !Array.isArray(base)) { + if (typeof base.color === 'string') state.baseStyle.color = base.color; + if (typeof base.bold === 'boolean') state.baseStyle.bold = base.bold; + if (typeof base.italic === 'boolean') state.baseStyle.italic = base.italic; + if (typeof base.underline === 'boolean') state.baseStyle.underline = base.underline; + if (typeof base.strike === 'boolean') state.baseStyle.strike = base.strike; + if (Number.isFinite(base.size)) state.baseStyle.size = Math.max(1, base.size); + if (typeof base.font === 'string') state.baseStyle.font = base.font; + } + if (settings.align === 'left' || settings.align === 'center' || settings.align === 'right' || settings.align === 'justify') { + state.align = settings.align; + } + if (typeof settings.smoothing === 'boolean') state.smoothing = settings.smoothing; + if (typeof settings.resetCharTransformsOnText === 'boolean') state.resetCharTransformsOnText = settings.resetCharTransformsOnText; + if (Number.isFinite(settings.letterSpacing)) state.letterSpacing = settings.letterSpacing; + if (Number.isFinite(settings.lineSpacing)) state.lineSpacing = Math.max(0, settings.lineSpacing); + if (settings.typingSpeeds && typeof settings.typingSpeeds === 'object' && !Array.isArray(settings.typingSpeeds)) { + for (const group of ['default', 'symbols', 'punctuation', 'numbers']) { + if (Number.isFinite(settings.typingSpeeds[group])) { + state.typingSpeeds[group] = Math.max(0, settings.typingSpeeds[group]); + } + } + } + if (settings.customTypingSpeeds && typeof settings.customTypingSpeeds === 'object' && !Array.isArray(settings.customTypingSpeeds)) { + state.customTypingSpeeds = {}; + for (const char in settings.customTypingSpeeds) { + if (Number.isFinite(settings.customTypingSpeeds[char])) { + state.customTypingSpeeds[char] = Math.max(0, settings.customTypingSpeeds[char]); + } + } + } + if (settings.maxWidth === null || Number.isFinite(settings.maxWidth)) state.maxWidth = settings.maxWidth; + if (settings.maxHeight === null || Number.isFinite(settings.maxHeight)) state.maxHeight = settings.maxHeight; + + const copySettings = (source, target, keys) => { + if (!source || typeof source !== 'object' || Array.isArray(source)) return; + for (const key of keys) { + if (typeof target[key] === 'boolean' && typeof source[key] === 'boolean') { + target[key] = source[key]; + } else if (typeof target[key] === 'number' && Number.isFinite(source[key])) { + target[key] = source[key]; + } else if (typeof target[key] === 'string' && typeof source[key] === 'string') { + target[key] = source[key]; + } + } + }; + + copySettings(settings.textShadow, state.textShadow, ['enabled', 'color', 'opacity', 'blur', 'offsetX', 'offsetY']); + copySettings(settings.textBackground, state.textBackground, ['enabled', 'color', 'opacity', 'radius', 'padding']); + copySettings(settings.textBorder, state.textBorder, ['enabled', 'color', 'opacity', 'size']); + state.shapeKey = null; + state.layoutKey = null; + state.layout = null; + state.paintDirty = true; + state.paintFingerprint = null; + return true; + } function getCharOverride(state, index) { if (!state.charOverrides[index]) { @@ -578,6 +582,12 @@ Enjoy!! :D return state.charOverrides[index]; } + function resetCharacterTransformsState(state) { + if (!Object.keys(state.charOverrides).length) return; + state.charOverrides = {}; + state.charOverridesVersion++; + } + const SVG_NS = 'http://www.w3.org/2000/svg'; let measureSvg = null; @@ -658,61 +668,61 @@ Enjoy!! :D }; } - function getGlyphBitmap(char, fontFamily, size, weight, style, color) { - const key = glyphCacheKey(char, fontFamily, size, weight, style, color); - let entry = glyphCache.get(key); + function getGlyphBitmap(char, fontFamily, size, weight, style, color) { + const key = glyphCacheKey(char, fontFamily, size, weight, style, color); + let entry = glyphCache.get(key); if (!entry) { entry = rasterizeGlyph(char, fontFamily, size, weight, style, color); glyphCache.set(key, entry); } - return entry; - } - - function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { - const stroke = (x1, x2) => { - if (x2 <= x1) return; - ctx.beginPath(); - ctx.moveTo(x1, y); - ctx.lineTo(x2, y); - ctx.stroke(); - }; - - try { - const image = glyph.canvas.getContext('2d').getImageData(0, 0, glyph.canvas.width, glyph.canvas.height); - const rowStart = Math.max(0, Math.floor(y - glyphY - lineWidth / 2)); - const rowEnd = Math.min(glyph.canvas.height - 1, Math.ceil(y - glyphY + lineWidth / 2)); - const columnStart = Math.max(0, Math.floor(startX - glyphX)); - const columnEnd = Math.min(glyph.canvas.width - 1, Math.ceil(endX - glyphX)); - const ink = new Uint8Array(glyph.canvas.width); - - for (let py = rowStart; py <= rowEnd; py++) { - for (let px = columnStart; px <= columnEnd; px++) { - if (image.data[(py * glyph.canvas.width + px) * 4 + 3] > 16) ink[px] = 1; - } - } - - const padding = Math.max(1, Math.ceil(lineWidth / 2) + 1); - let segmentStart = startX; - let px = columnStart; - while (px <= columnEnd) { - if (!ink[px]) { - px++; - continue; - } - const inkStart = px; - while (px <= columnEnd && ink[px]) px++; - const skipStart = Math.max(startX, glyphX + inkStart - padding); - const skipEnd = Math.min(endX, glyphX + px + padding); - stroke(segmentStart, skipStart); - segmentStart = Math.max(segmentStart, skipEnd); - } - stroke(segmentStart, endX); - } catch (e) { - stroke(startX, endX); - } - } - - function invalidateGlyphCacheForFamily(fontFamily) { + return entry; + } + + function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { + const stroke = (x1, x2) => { + if (x2 <= x1) return; + ctx.beginPath(); + ctx.moveTo(x1, y); + ctx.lineTo(x2, y); + ctx.stroke(); + }; + + try { + const image = glyph.canvas.getContext('2d').getImageData(0, 0, glyph.canvas.width, glyph.canvas.height); + const rowStart = Math.max(0, Math.floor(y - glyphY - lineWidth / 2)); + const rowEnd = Math.min(glyph.canvas.height - 1, Math.ceil(y - glyphY + lineWidth / 2)); + const columnStart = Math.max(0, Math.floor(startX - glyphX)); + const columnEnd = Math.min(glyph.canvas.width - 1, Math.ceil(endX - glyphX)); + const ink = new Uint8Array(glyph.canvas.width); + + for (let py = rowStart; py <= rowEnd; py++) { + for (let px = columnStart; px <= columnEnd; px++) { + if (image.data[(py * glyph.canvas.width + px) * 4 + 3] > 16) ink[px] = 1; + } + } + + const padding = Math.max(1, Math.ceil(lineWidth / 2) + 1); + let segmentStart = startX; + let px = columnStart; + while (px <= columnEnd) { + if (!ink[px]) { + px++; + continue; + } + const inkStart = px; + while (px <= columnEnd && ink[px]) px++; + const skipStart = Math.max(startX, glyphX + inkStart - padding); + const skipEnd = Math.min(endX, glyphX + px + padding); + stroke(segmentStart, skipStart); + segmentStart = Math.max(segmentStart, skipEnd); + } + stroke(segmentStart, endX); + } catch (e) { + stroke(startX, endX); + } + } + + function invalidateGlyphCacheForFamily(fontFamily) { for (const key of glyphCache.keys()) { if (key.indexOf('\u0001' + fontFamily + '\u0001') !== -1) glyphCache.delete(key); } @@ -917,24 +927,24 @@ Enjoy!! :D for (let li = 0; li < lines.length; li++) { const line = lines[li]; - const runs = splitShapingRuns(line, state.charOverrides, globalIndex); - - let runStartX = 0; - for (const run of runs) { - run.fontStyle = svgFontStyle(run.style); - run.runStartX = runStartX; - run.positions = []; - let charX = 0; - for (let i = 0; i < run.chars.length; i++) { - const w = run.chars[i]._width; - run.positions.push({ - shapedX: charX, - advance: w - }); - addTagIndex(charsByTag, run.startIndex + i, run.chars[i].tags); - charX += w; - runStartX += w; - } + const runs = splitShapingRuns(line, state.charOverrides, globalIndex); + + let runStartX = 0; + for (const run of runs) { + run.fontStyle = svgFontStyle(run.style); + run.runStartX = runStartX; + run.positions = []; + let charX = 0; + for (let i = 0; i < run.chars.length; i++) { + const w = run.chars[i]._width; + run.positions.push({ + shapedX: charX, + advance: w + }); + addTagIndex(charsByTag, run.startIndex + i, run.chars[i].tags); + charX += w; + runStartX += w; + } run.runWidth = runStartX - run.runStartX; } @@ -1040,11 +1050,11 @@ Enjoy!! :D } } - function schedulePaint(target, state) { - state.paintDirty = true; - state.paintFingerprint = null; - if (state.visible) scheduleRender(target); - } + function schedulePaint(target, state) { + state.paintDirty = true; + state.paintFingerprint = null; + if (state.visible) scheduleRender(target); + } function getLayoutKey(state) { return getShapeKey(state) + '\u0002' + state.charOverridesVersion; @@ -1075,11 +1085,11 @@ Enjoy!! :D if (!state.visible) return; const shapeKey = getShapeKey(state); - let layout = state.layout; - if (state.shapeKey !== shapeKey || !layout) { - const richChars = parseRichText(state.rawText, state.baseStyle); - applyCharacterStyleOverrides(richChars, state.charStyleOverrides); - for (const family of new Set(richChars.map(rc => rc.font))) { + let layout = state.layout; + if (state.shapeKey !== shapeKey || !layout) { + const richChars = parseRichText(state.rawText, state.baseStyle); + applyCharacterStyleOverrides(richChars, state.charStyleOverrides); + for (const family of new Set(richChars.map(rc => rc.font))) { ensureDocumentFont(family); } const svg = getMeasureSvg(); @@ -1099,19 +1109,19 @@ Enjoy!! :D const lineHeight = state.baseStyle.size * state.lineSpacing; const totalHeight = lines.length * lineHeight; - const decorationPad = Math.max( - 0, - Number(state.textBackground.padding) || 0, - Number(state.textBorder.size) || 0, - (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetX) || 0), - (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetY) || 0) - ); - const pad = Math.max(64, state.baseStyle.size * 2, decorationPad + state.baseStyle.size); + const decorationPad = Math.max( + 0, + Number(state.textBackground.padding) || 0, + Number(state.textBorder.size) || 0, + (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetX) || 0), + (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetY) || 0) + ); + const pad = Math.max(64, state.baseStyle.size * 2, decorationPad + state.baseStyle.size); const effectiveMaxWidth = state.maxWidth && state.maxWidth > 0 ? state.maxWidth : maxWidth; const effectiveMaxHeight = state.maxHeight && state.maxHeight > 0 ? state.maxHeight : totalHeight; - const docW = Math.ceil(effectiveMaxWidth + pad * 2); + const docW = Math.ceil(effectiveMaxWidth + pad * 2); const docH = Math.ceil(effectiveMaxHeight + pad * 2); const originX = docW / 2; @@ -1128,30 +1138,30 @@ Enjoy!! :D const letterSpacing = state.letterSpacing || 0; for (let li = 0; li < lines.length; li++) { - const runs = lineRuns[li]; - const lineW = lineWidths[li]; - const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && - effectiveMaxWidth > lineW ? - line.filter(rc => rc.char === ' ').length : 0; - const justifyExtra = justifySpaceCount > 0 ? - (effectiveMaxWidth - lineW) / justifySpaceCount : 0; - let lineX; - - if (state.align === 'left') { - lineX = originX - effectiveMaxWidth / 2; - } else if (state.align === 'right') { - lineX = originX + effectiveMaxWidth / 2 - lineW; - } else if (state.align === 'justify') { - lineX = originX - effectiveMaxWidth / 2; - } else { - lineX = originX - lineW / 2; - } + const runs = lineRuns[li]; + const lineW = lineWidths[li]; + const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && + effectiveMaxWidth > lineW ? + line.filter(rc => rc.char === ' ').length : 0; + const justifyExtra = justifySpaceCount > 0 ? + (effectiveMaxWidth - lineW) / justifySpaceCount : 0; + let lineX; + + if (state.align === 'left') { + lineX = originX - effectiveMaxWidth / 2; + } else if (state.align === 'right') { + lineX = originX + effectiveMaxWidth / 2 - lineW; + } else if (state.align === 'justify') { + lineX = originX - effectiveMaxWidth / 2; + } else { + lineX = originX - lineW / 2; + } const textStartY = originY - totalHeight / 2; - const baseCenterY = textStartY + (li * lineHeight) + lineHeight * 0.5; - - let spacesBefore = 0; - for (const run of runs) { + const baseCenterY = textStartY + (li * lineHeight) + lineHeight * 0.5; + + let spacesBefore = 0; + for (const run of runs) { const rc0 = run.chars[0]; const underline = !!rc0.underline; @@ -1163,12 +1173,12 @@ Enjoy!! :D const rc = run.chars[i]; const pos = run.positions[i]; const index = run.startIndex + i; - const o = run.hasOverride ? - (state.charOverrides[index] || EMPTY_OVERRIDE) : - EMPTY_OVERRIDE; - - const charCenterX = runOriginX + pos.shapedX + pos.advance / 2 + - spacesBefore * justifyExtra + o.x; + const o = run.hasOverride ? + (state.charOverrides[index] || EMPTY_OVERRIDE) : + EMPTY_OVERRIDE; + + const charCenterX = runOriginX + pos.shapedX + pos.advance / 2 + + spacesBefore * justifyExtra + o.x; const charCenterY = baseCenterY - o.y; const rotation = o.rotation || 0; const opacity = o.opacity == null ? 1 : o.opacity; @@ -1186,25 +1196,25 @@ Enjoy!! :D letterSpacing, underline, strike, - width: pos.advance - }); - - if (rc.char === ' ') spacesBefore++; + width: pos.advance + }); + + if (rc.char === ' ') spacesBefore++; let box = charBoxes[index]; if (!box) { - box = { - index, - char: rc.char, - tags: rc.tags, - width: pos.advance, - height: rc.size, - style: characterStyleSnapshot(rc) - }; - charBoxes[index] = box; - } - box.style = characterStyleSnapshot(rc); - box.x = charCenterX - originX; + box = { + index, + char: rc.char, + tags: rc.tags, + width: pos.advance, + height: rc.size, + style: characterStyleSnapshot(rc) + }; + charBoxes[index] = box; + } + box.style = characterStyleSnapshot(rc); + box.x = charCenterX - originX; box.y = originY - charCenterY; } } @@ -1222,7 +1232,7 @@ Enjoy!! :D } state.paintFingerprint = fingerprint; - const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); + const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); pushCanvasToDrawable(target, canvas, docW, docH); state.paintDirty = false; } @@ -1259,7 +1269,7 @@ Enjoy!! :D const DEST_SCALE = GLYPH_OVERSAMPLE; - function compositeGlyphsToCanvas(state, paintOps, docW, docH, textWidth, textHeight, originX, originY) { + function compositeGlyphsToCanvas(state, paintOps, docW, docH, textWidth, textHeight, originX, originY) { let canvas = state.paintCanvas; if (!canvas) { canvas = document.createElement('canvas'); @@ -1272,29 +1282,29 @@ Enjoy!! :D if (canvas.width !== pixelW || canvas.height !== pixelH) { canvas.width = pixelW; canvas.height = pixelH; - } - const ctx = state.paintCtx; - ctx.imageSmoothingEnabled = !!state.smoothing; - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, pixelW, pixelH); - ctx.textBaseline = 'alphabetic'; - - const background = state.textBackground; - if (background.enabled) { - const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; - const width = textWidth * DEST_SCALE + padding * 2; - const height = textHeight * DEST_SCALE + padding * 2; - const x = originX * DEST_SCALE - width / 2; - const y = originY * DEST_SCALE - height / 2; - ctx.save(); - ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; - ctx.fillStyle = background.color; - const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); - ctx.beginPath(); - ctx.roundRect(x, y, width, height, radius); - ctx.fill(); - ctx.restore(); - } + } + const ctx = state.paintCtx; + ctx.imageSmoothingEnabled = !!state.smoothing; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, pixelW, pixelH); + ctx.textBaseline = 'alphabetic'; + + const background = state.textBackground; + if (background.enabled) { + const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; + const width = textWidth * DEST_SCALE + padding * 2; + const height = textHeight * DEST_SCALE + padding * 2; + const x = originX * DEST_SCALE - width / 2; + const y = originY * DEST_SCALE - height / 2; + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; + ctx.fillStyle = background.color; + const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.fill(); + ctx.restore(); + } for (let i = 0; i < paintOps.length; i++) { const op = paintOps[i]; @@ -1312,71 +1322,71 @@ Enjoy!! :D const drawXEm = op.x - advanceCenterXEm; const drawYEm = (op.y + baselineYFromLineCenter) - glyph.baselineOriginYEm; - const hasOpacity = op.opacity != null && op.opacity !== 1; - const hasRotation = !!op.rotation; - - const drawGlyph = (drawX, drawY) => { - ctx.drawImage(glyph.canvas, drawX, drawY); - }; - - if (state.textShadow.enabled) { - ctx.save(); - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; - ctx.shadowColor = state.textShadow.color; - ctx.shadowBlur = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; - ctx.shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE; - ctx.shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE; - if (!hasRotation) { - drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - } else { - const cx = op.x * DEST_SCALE; - const cy = op.y * DEST_SCALE; - ctx.translate(cx, cy); - ctx.rotate(-op.rotation * Math.PI / 180); - ctx.translate(-cx, -cy); - drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - } - ctx.restore(); - } - - if (op.underline || op.strike) { - ctx.save(); - if (hasOpacity) ctx.globalAlpha = op.opacity; - ctx.strokeStyle = op.color; - ctx.lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; - if (op.strike) { - const sy = op.y * DEST_SCALE; - ctx.beginPath(); - ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); - ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); - ctx.stroke(); - } - ctx.restore(); - } - - if (state.textBorder.enabled && Number(state.textBorder.size) > 0) { - ctx.save(); - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textBorder.opacity) || 0)) / 100; - ctx.shadowColor = state.textBorder.color; - ctx.shadowBlur = 0; - const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; - const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; - for (const [offsetX, offsetY] of borderOffsets) { - if (!hasRotation) { - drawGlyph(drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); - } else { - const cx = op.x * DEST_SCALE; - const cy = op.y * DEST_SCALE; - ctx.save(); - ctx.translate(cx, cy); - ctx.rotate(-op.rotation * Math.PI / 180); - ctx.translate(-cx, -cy); - drawGlyph(drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); - ctx.restore(); - } - } - ctx.restore(); - } + const hasOpacity = op.opacity != null && op.opacity !== 1; + const hasRotation = !!op.rotation; + + const drawGlyph = (drawX, drawY) => { + ctx.drawImage(glyph.canvas, drawX, drawY); + }; + + if (state.textShadow.enabled) { + ctx.save(); + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; + ctx.shadowColor = state.textShadow.color; + ctx.shadowBlur = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; + ctx.shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE; + ctx.shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE; + if (!hasRotation) { + drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + } else { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.translate(cx, cy); + ctx.rotate(-op.rotation * Math.PI / 180); + ctx.translate(-cx, -cy); + drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + } + ctx.restore(); + } + + if (op.underline || op.strike) { + ctx.save(); + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + ctx.lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + if (op.strike) { + const sy = op.y * DEST_SCALE; + ctx.beginPath(); + ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); + ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); + ctx.stroke(); + } + ctx.restore(); + } + + if (state.textBorder.enabled && Number(state.textBorder.size) > 0) { + ctx.save(); + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textBorder.opacity) || 0)) / 100; + ctx.shadowColor = state.textBorder.color; + ctx.shadowBlur = 0; + const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; + const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; + for (const [offsetX, offsetY] of borderOffsets) { + if (!hasRotation) { + drawGlyph(drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); + } else { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-op.rotation * Math.PI / 180); + ctx.translate(-cx, -cy); + drawGlyph(drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); + ctx.restore(); + } + } + ctx.restore(); + } if (!hasRotation) { const px = Math.round(drawXEm * DEST_SCALE); @@ -1397,40 +1407,40 @@ Enjoy!! :D ctx.translate(cx, cy); ctx.rotate(-op.rotation * Math.PI / 180); ctx.translate(-cx, -cy); - ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - ctx.restore(); - } - - if (op.underline) { - ctx.save(); - if (hasOpacity) ctx.globalAlpha = op.opacity; - ctx.strokeStyle = op.color; - const lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; - ctx.lineWidth = lineWidth; - const uy = (op.y + baselineYFromLineCenter + fontSize * 0.06) * DEST_SCALE; - const startX = (op.x - op.width / 2) * DEST_SCALE; - const endX = (op.x + op.width / 2) * DEST_SCALE; - if (hasRotation) { - ctx.beginPath(); - ctx.moveTo(startX, uy); - ctx.lineTo(endX, uy); - ctx.stroke(); - } else { - strokeUnderline(ctx, glyph, Math.round(drawXEm * DEST_SCALE), Math.round(drawYEm * DEST_SCALE), startX, endX, uy, lineWidth); - } - ctx.restore(); - } - - } + ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + ctx.restore(); + } + + if (op.underline) { + ctx.save(); + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + const lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + ctx.lineWidth = lineWidth; + const uy = (op.y + baselineYFromLineCenter + fontSize * 0.06) * DEST_SCALE; + const startX = (op.x - op.width / 2) * DEST_SCALE; + const endX = (op.x + op.width / 2) * DEST_SCALE; + if (hasRotation) { + ctx.beginPath(); + ctx.moveTo(startX, uy); + ctx.lineTo(endX, uy); + ctx.stroke(); + } else { + strokeUnderline(ctx, glyph, Math.round(drawXEm * DEST_SCALE), Math.round(drawYEm * DEST_SCALE), startX, endX, uy, lineWidth); + } + ctx.restore(); + } + + } return canvas; } - function installLinearFiltering(skin, state) { - skin.useNearest = function(scale) { - if (!state.smoothing) return true; - const s = Math.max(Math.abs(scale[0]), Math.abs(scale[1])); - return s >= (DEST_SCALE * 100 - 5); + function installLinearFiltering(skin, state) { + skin.useNearest = function(scale) { + if (!state.smoothing) return true; + const s = Math.max(Math.abs(scale[0]), Math.abs(scale[1])); + return s >= (DEST_SCALE * 100 - 5); }; } @@ -1441,18 +1451,18 @@ Enjoy!! :D const rotationCenter = [width / 2, height / 2]; if (state.skinId === null) { - const BitmapSkin = renderer.exports.BitmapSkin; - const skinId = renderer._nextSkinId++; - const skin = new BitmapSkin(skinId, renderer); - installLinearFiltering(skin, state); + const BitmapSkin = renderer.exports.BitmapSkin; + const skinId = renderer._nextSkinId++; + const skin = new BitmapSkin(skinId, renderer); + installLinearFiltering(skin, state); skin.setBitmap(canvas, DEST_SCALE, rotationCenter); renderer._allSkins[skinId] = skin; state.skinId = skinId; } else { - renderer.updateBitmapSkin(state.skinId, canvas, DEST_SCALE, rotationCenter); - const current = renderer._allSkins[state.skinId]; - if (current && !Object.prototype.hasOwnProperty.call(current, 'useNearest')) { - installLinearFiltering(current, state); + renderer.updateBitmapSkin(state.skinId, canvas, DEST_SCALE, rotationCenter); + const current = renderer._allSkins[state.skinId]; + if (current && !Object.prototype.hasOwnProperty.call(current, 'useNearest')) { + installLinearFiltering(current, state); } } renderer.updateDrawableSkinId(target.drawableID, state.skinId); @@ -1727,8 +1737,8 @@ Enjoy!! :D target._irisCharSequenceActive = threads.length > 0; } - function charThreadsStillActive(target) { - const threads = target._irisCharSequenceThreads; + function charThreadsStillActive(target) { + const threads = target._irisCharSequenceThreads; if (!threads || !threads.length) { target._irisCharSequenceActive = false; return false; @@ -1743,25 +1753,25 @@ Enjoy!! :D target._irisCharSequenceActive = false; return false; } - return true; - } - - function startTypingCharacterHat(target, char, index) { - const current = { - char, - index - }; - target._irisTypingChar = current; - const threads = runtime.startHats('irisText_onCharacterType', null, target); - if (Array.isArray(threads)) { - for (const thread of threads) { - thread._irisChar = current; - } - } - target._irisTypingChar = null; - } - - class IrisText { + return true; + } + + function startTypingCharacterHat(target, char, index) { + const current = { + char, + index + }; + target._irisTypingChar = current; + const threads = runtime.startHats('irisText_onCharacterType', null, target); + if (Array.isArray(threads)) { + for (const thread of threads) { + thread._irisChar = current; + } + } + target._irisTypingChar = null; + } + + class IrisText { constructor() { this._onTargetRemoved = this._onTargetRemoved.bind(this); runtime.on('targetWasRemoved', this._onTargetRemoved); @@ -1815,13 +1825,13 @@ Enjoy!! :D const funcExpr = compiler.source; compiler.source = temp; compiler.source += `runtime.ext_irisText._scheduleCompiledTag(${compiler.descendInput(node.tag).asString()}, ${funcExpr}, thread, target);\n`; - }, - currentCharValue: (node, compiler, imports) => { - return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.char : (target && target._irisTypingChar ? target._irisTypingChar.char : ""))', imports.TYPE_STRING); - }, - currentCharIndex: (node, compiler, imports) => { - return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.index + 1 : (target && target._irisTypingChar ? target._irisTypingChar.index + 1 : 0))', imports.TYPE_NUMBER); - } + }, + currentCharValue: (node, compiler, imports) => { + return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.char : (target && target._irisTypingChar ? target._irisTypingChar.char : ""))', imports.TYPE_STRING); + }, + currentCharIndex: (node, compiler, imports) => { + return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.index + 1 : (target && target._irisTypingChar ? target._irisTypingChar.index + 1 : 0))', imports.TYPE_NUMBER); + } } }; } @@ -1858,6 +1868,17 @@ Enjoy!! :D defaultValue: DEFAULT_TEXT } } + }, + { + opcode: 'addLine', + blockType: Scratch.BlockType.COMMAND, + text: 'add line [TEXT]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: "This is another line!" + } + } }, { opcode: 'clearText', @@ -1906,452 +1927,475 @@ Enjoy!! :D } } }, - { - opcode: 'setAlign', - blockType: Scratch.BlockType.COMMAND, + { + opcode: 'setAlign', + blockType: Scratch.BlockType.COMMAND, text: 'align text [ALIGN]', arguments: { ALIGN: { type: Scratch.ArgumentType.STRING, menu: 'ALIGN', defaultValue: 'center' - } - } - }, - { - opcode: 'setSmoothing', - blockType: Scratch.BlockType.COMMAND, - text: 'smoothing [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Typing' - }, - { - opcode: 'typeText', - blockType: Scratch.BlockType.COMMAND, - text: 'type [TEXT]', - arguments: { - TEXT: { - type: Scratch.ArgumentType.STRING, - defaultValue: DEFAULT_TEXT - } - } - }, - { - opcode: 'skipTyping', - blockType: Scratch.BlockType.COMMAND, - text: 'skip typing' - }, - { - opcode: 'stopTyping', - blockType: Scratch.BlockType.COMMAND, - text: 'stop typing' - }, - { - opcode: 'setTypingSpeed', - blockType: Scratch.BlockType.COMMAND, - text: 'set typing speed for [GROUP] characters to [SECONDS] seconds', - arguments: { - GROUP: { - type: Scratch.ArgumentType.STRING, - menu: 'TYPING_GROUP', - defaultValue: 'default' - }, - SECONDS: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0.05 - } - } - }, - { - opcode: 'setCustomTypingSpeed', - blockType: Scratch.BlockType.COMMAND, - text: 'set typing speed for custom characters [CHARS] to [SECONDS] seconds', - arguments: { - CHARS: { - type: Scratch.ArgumentType.STRING, - defaultValue: '…' - }, - SECONDS: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0.2 - } - } - }, - { - opcode: 'onCharacterType', - blockType: Scratch.BlockType.EVENT, - isEdgeActivated: false, - shouldRestartExistingThreads: false, - text: 'on character type' - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Shadow' - }, - { - opcode: 'setTextShadowEnabled', - blockType: Scratch.BlockType.COMMAND, - text: 'text shadow [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setTextShadowColor', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow color to [COLOR]', - arguments: { - COLOR: { - type: Scratch.ArgumentType.COLOR - } - } - }, - { - opcode: 'setTextShadowOpacity', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow opacity to [PCT] %', - arguments: { - PCT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 50 - } - } - }, - { - opcode: 'setTextShadowBlur', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow blur to [BLUR]', - arguments: { - BLUR: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 8 - } - } - }, - { - opcode: 'setTextShadowOffset', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow offset x [X] y [Y]', - arguments: { - X: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 4 - }, - Y: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 4 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Background' - }, - { - opcode: 'setTextBackgroundEnabled', - blockType: Scratch.BlockType.COMMAND, - text: 'text background [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setTextBackgroundColor', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background color to [COLOR]', - arguments: { - COLOR: { - type: Scratch.ArgumentType.COLOR - } - } - }, - { - opcode: 'setTextBackgroundOpacity', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background opacity to [PCT] %', - arguments: { - PCT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 50 - } - } - }, - { - opcode: 'setTextBackgroundRadius', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background radius to [RADIUS]', - arguments: { - RADIUS: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 8 - } - } - }, - { - opcode: 'setTextBackgroundPadding', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background padding to [PADDING]', - arguments: { - PADDING: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 8 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Border' - }, - { - opcode: 'setTextBorderEnabled', - blockType: Scratch.BlockType.COMMAND, - text: 'text border [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setTextBorderColor', - blockType: Scratch.BlockType.COMMAND, - text: 'set text border color to [COLOR]', - arguments: { - COLOR: { - type: Scratch.ArgumentType.COLOR - } - } - }, - { - opcode: 'setTextBorderOpacity', - blockType: Scratch.BlockType.COMMAND, - text: 'set text border opacity to [PCT] %', - arguments: { - PCT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 100 - } - } - }, - { - opcode: 'setTextBorderSize', - blockType: Scratch.BlockType.COMMAND, - text: 'set text border size to [SIZE]', - arguments: { - SIZE: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 2 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Size Constraints' + } + } }, { - opcode: 'setMaxWidth', + opcode: 'setSmoothing', blockType: Scratch.BlockType.COMMAND, - text: 'set max width to [WIDTH]', + text: 'smoothing [ENABLED]', arguments: { - WIDTH: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' } } }, { - opcode: 'setMaxHeight', + blockType: Scratch.BlockType.LABEL, + text: 'Typing' + }, + { + opcode: 'typeText', blockType: Scratch.BlockType.COMMAND, - text: 'set max height to [HEIGHT]', + text: 'type [TEXT]', arguments: { - HEIGHT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: DEFAULT_TEXT } } }, { - opcode: 'setMaxDimensions', + opcode: 'typeNewLine', blockType: Scratch.BlockType.COMMAND, - text: 'set max width to [WIDTH] and max height to [HEIGHT]', + text: 'type new line [TEXT]', arguments: { - WIDTH: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 - }, - HEIGHT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: DEFAULT_TEXT } } }, { - opcode: 'clearMaxDimensions', + opcode: 'skipTyping', blockType: Scratch.BlockType.COMMAND, - text: 'remove max width and height bounds' - }, - { - opcode: 'setLetterSpacing', - blockType: Scratch.BlockType.COMMAND, - text: 'set letter spacing to [SPACE]', - arguments: { - SPACE: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Settings' - }, - { - opcode: 'exportSettings', - blockType: Scratch.BlockType.REPORTER, - text: 'export settings as JSON' - }, - { - opcode: 'applyExportSettings', - blockType: Scratch.BlockType.COMMAND, - text: 'apply JSON export settings [SETTINGS] to [SPRITE]', - arguments: { - SETTINGS: { - type: Scratch.ArgumentType.STRING, - defaultValue: '{"version":1}' - }, - SPRITE: { - type: Scratch.ArgumentType.STRING, - menu: 'SPRITE', - defaultValue: '_myself_' - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Per-Character Scripting' + text: 'skip typing' }, { - opcode: 'onRenderComplete', - blockType: Scratch.BlockType.EVENT, - isEdgeActivated: false, - shouldRestartExistingThreads: true, - text: 'when render finishes' + opcode: 'stopTyping', + blockType: Scratch.BlockType.COMMAND, + text: 'stop typing' }, { - opcode: 'scheduleTagBlocks', + opcode: 'setTypingSpeed', blockType: Scratch.BlockType.COMMAND, - text: 'schedule blocks for tag [TAG]', - branchCount: 1, + text: 'set typing speed for [GROUP] characters to [SECONDS] seconds', arguments: { - TAG: { + GROUP: { type: Scratch.ArgumentType.STRING, - defaultValue: 'shake' + menu: 'TYPING_GROUP', + defaultValue: 'default' + }, + SECONDS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0.05 } } }, { - opcode: 'currentCharValue', - blockType: Scratch.BlockType.REPORTER, - text: 'character' - }, - { - opcode: 'currentCharIndex', - blockType: Scratch.BlockType.REPORTER, - text: 'character #' + opcode: 'setCustomTypingSpeed', + blockType: Scratch.BlockType.COMMAND, + text: 'set typing speed for custom characters [CHARS] to [SECONDS] seconds', + arguments: { + CHARS: { + type: Scratch.ArgumentType.STRING, + defaultValue: '…' + }, + SECONDS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0.2 + } + } }, { - opcode: 'waitForCharsToFinish', - blockType: Scratch.BlockType.COMMAND, - text: 'wait until characters finish rendering' + opcode: 'onCharacterType', + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: false, + text: 'on character type' }, { blockType: Scratch.BlockType.LABEL, - text: 'Character Transforms' + text: 'Text Shadow' }, { - opcode: 'setCharPos', + opcode: 'setTextShadowEnabled', blockType: Scratch.BlockType.COMMAND, - text: 'set character [INDEX] position to x: [X] y: [Y]', + text: 'text shadow [ENABLED]', arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - X: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 - }, - Y: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' } } }, { - opcode: 'changeCharPos', + opcode: 'setTextShadowColor', blockType: Scratch.BlockType.COMMAND, - text: 'change character [INDEX] position by x: [X] y: [Y]', + text: 'set shadow color to [COLOR]', arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - X: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 - }, - Y: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 + COLOR: { + type: Scratch.ArgumentType.COLOR } } }, { - opcode: 'setCharRotation', + opcode: 'setTextShadowOpacity', blockType: Scratch.BlockType.COMMAND, - text: 'set character [INDEX] rotation to [DEG] degrees', + text: 'set shadow opacity to [PCT] %', arguments: { - INDEX: { + PCT: { type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - DEG: { - type: Scratch.ArgumentType.ANGLE, - defaultValue: 0 + defaultValue: 50 } } }, { - opcode: 'changeCharRotation', + opcode: 'setTextShadowBlur', blockType: Scratch.BlockType.COMMAND, - text: 'change character [INDEX] rotation by [DEG] degrees', + text: 'set shadow blur to [BLUR]', arguments: { - INDEX: { + BLUR: { type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - DEG: { - type: Scratch.ArgumentType.ANGLE, - defaultValue: 0 + defaultValue: 8 + } + } + }, + { + opcode: 'setTextShadowOffset', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow offset x [X] y [Y]', + arguments: { + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 4 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 4 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Background' + }, + { + opcode: 'setTextBackgroundEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text background [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextBackgroundColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextBackgroundOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'setTextBackgroundRadius', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background radius to [RADIUS]', + arguments: { + RADIUS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + opcode: 'setTextBackgroundPadding', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background padding to [PADDING]', + arguments: { + PADDING: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Border' + }, + { + opcode: 'setTextBorderEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text border [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextBorderColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextBorderOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'setTextBorderSize', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border size to [SIZE]', + arguments: { + SIZE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 2 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Size Constraints' + }, + { + opcode: 'setMaxWidth', + blockType: Scratch.BlockType.COMMAND, + text: 'set max width to [WIDTH]', + arguments: { + WIDTH: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + } + } + }, + { + opcode: 'setMaxHeight', + blockType: Scratch.BlockType.COMMAND, + text: 'set max height to [HEIGHT]', + arguments: { + HEIGHT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + } + } + }, + { + opcode: 'setMaxDimensions', + blockType: Scratch.BlockType.COMMAND, + text: 'set max width to [WIDTH] and max height to [HEIGHT]', + arguments: { + WIDTH: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + }, + HEIGHT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + } + } + }, + { + opcode: 'clearMaxDimensions', + blockType: Scratch.BlockType.COMMAND, + text: 'remove max width and height bounds' + }, + { + opcode: 'setLetterSpacing', + blockType: Scratch.BlockType.COMMAND, + text: 'set letter spacing to [SPACE]', + arguments: { + SPACE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Settings' + }, + { + opcode: 'exportSettings', + blockType: Scratch.BlockType.REPORTER, + text: 'export settings as JSON' + }, + { + opcode: 'applyExportSettings', + blockType: Scratch.BlockType.COMMAND, + text: 'apply JSON export settings [SETTINGS] to [SPRITE]', + arguments: { + SETTINGS: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"version":1}' + }, + SPRITE: { + type: Scratch.ArgumentType.STRING, + menu: 'SPRITE', + defaultValue: '_myself_' + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Per-Character Scripting' + }, + { + opcode: 'onRenderComplete', + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: true, + text: 'when render finishes' + }, + { + opcode: 'scheduleTagBlocks', + blockType: Scratch.BlockType.COMMAND, + text: 'schedule blocks for tag [TAG]', + branchCount: 1, + arguments: { + TAG: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'shake' + } + } + }, + { + opcode: 'currentCharValue', + blockType: Scratch.BlockType.REPORTER, + text: 'character' + }, + { + opcode: 'currentCharIndex', + blockType: Scratch.BlockType.REPORTER, + text: 'character #' + }, + { + opcode: 'waitForCharsToFinish', + blockType: Scratch.BlockType.COMMAND, + text: 'wait until characters finish rendering' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Character Transforms' + }, + { + opcode: 'setResetCharTransformsOnText', + blockType: Scratch.BlockType.COMMAND, + text: 'reset character transforms on show/type text [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setCharPos', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] position to x: [X] y: [Y]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'changeCharPos', + blockType: Scratch.BlockType.COMMAND, + text: 'change character [INDEX] position by x: [X] y: [Y]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'setCharRotation', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] rotation to [DEG] degrees', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + DEG: { + type: Scratch.ArgumentType.ANGLE, + defaultValue: 0 + } + } + }, + { + opcode: 'changeCharRotation', + blockType: Scratch.BlockType.COMMAND, + text: 'change character [INDEX] rotation by [DEG] degrees', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + DEG: { + type: Scratch.ArgumentType.ANGLE, + defaultValue: 0 } } }, @@ -2381,55 +2425,55 @@ Enjoy!! :D } } }, - { - opcode: 'resetAllCharTransforms', - blockType: Scratch.BlockType.COMMAND, - text: 'reset all character transforms' - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Character Style' - }, - { - opcode: 'getCharacterStyle', - blockType: Scratch.BlockType.REPORTER, - text: 'style of character [INDEX] as JSON', - arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - } - } - }, - { - opcode: 'setCharacterStyle', - blockType: Scratch.BlockType.COMMAND, - text: 'set style of character [INDEX] to JSON [STYLE]', - arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - STYLE: { - type: Scratch.ArgumentType.STRING, - defaultValue: '{"color":"#9966ff","bold":false,"italic":false,"underline":false,"strike":false,"size":32,"font":"Handwriting","tags":[]}' - } - } - }, - { - opcode: 'resetCharacterStyle', - blockType: Scratch.BlockType.COMMAND, - text: 'reset style of character [INDEX]', - arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Info' + { + opcode: 'resetAllCharTransforms', + blockType: Scratch.BlockType.COMMAND, + text: 'reset all character transforms' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Character Style' + }, + { + opcode: 'getCharacterStyle', + blockType: Scratch.BlockType.REPORTER, + text: 'style of character [INDEX] as JSON', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'setCharacterStyle', + blockType: Scratch.BlockType.COMMAND, + text: 'set style of character [INDEX] to JSON [STYLE]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + STYLE: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"color":"#9966ff","bold":false,"italic":false,"underline":false,"strike":false,"size":32,"font":"Handwriting","tags":[]}' + } + } + }, + { + opcode: 'resetCharacterStyle', + blockType: Scratch.BlockType.COMMAND, + text: 'reset style of character [INDEX]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Info' }, { opcode: 'getCharX', @@ -2571,32 +2615,32 @@ Enjoy!! :D } } ], - menus: { - ONOFF: { - items: ['on', 'off'] - }, - ALIGN: { - items: ['left', 'center', 'right', 'justify'] - }, - STYLE: { - items: ['bold', 'italic', 'underline', 'strikethrough'] - }, - TYPING_GROUP: { - items: ['default', 'symbols', 'punctuation', 'numbers'] - }, - FONT: { - items: 'getFontMenuItems', - isTypeable: true - }, - SPRITE: { - items: 'getSpriteMenuItems' - } - } - }; - } - - setText(args, util) { - if (util && util.thread && !ThreadClass) ThreadClass = util.thread.constructor; + menus: { + ONOFF: { + items: ['on', 'off'] + }, + ALIGN: { + items: ['left', 'center', 'right', 'justify'] + }, + STYLE: { + items: ['bold', 'italic', 'underline', 'strikethrough'] + }, + TYPING_GROUP: { + items: ['default', 'symbols', 'punctuation', 'numbers'] + }, + FONT: { + items: 'getFontMenuItems', + isTypeable: true + }, + SPRITE: { + items: 'getSpriteMenuItems' + } + } + }; + } + + setText(args, util) { + if (util && util.thread && !ThreadClass) ThreadClass = util.thread.constructor; const state = getState(util.target); const frame = util.stackFrame; @@ -2604,6 +2648,7 @@ Enjoy!! :D state.rawText = Scratch.Cast.toString(args.TEXT); state.finalText = stripMarkup(state.rawText); state.visible = true; + if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); frame.irisStages = splitWaitStages(state.rawText); frame.irisStageIndex = 0; frame.irisToken = ++state.revealToken; @@ -2628,88 +2673,113 @@ Enjoy!! :D frame.irisStageIndex++; if (frame.irisStageIndex >= frame.irisStages.length) return; - frame.irisWaitUntil = Date.now() + stage.waitAfter * 1000; - util.yieldTick(); - } - - typeText(args, util) { - if (util && util.thread && !ThreadClass) ThreadClass = util.thread.constructor; - const state = getState(util.target); - const frame = util.stackFrame; - - if (frame.irisTypingSteps === undefined) { - const text = Scratch.Cast.toString(args.TEXT); - state.rawText = ''; - state.finalText = stripMarkup(text); - state.visible = true; - state.revealToken++; - state.typingControl = null; - frame.irisTypingSteps = splitTypingSteps(text); - frame.irisTypingStepIndex = 0; - frame.irisTypingCharIndex = 0; - frame.irisTypedText = ''; - frame.irisTypingToken = state.revealToken; - frame.irisTypingWaitUntil = null; - } - - if (state.revealToken !== frame.irisTypingToken) return; - - if (state.typingControl === 'stop') { - state.typingControl = null; - state.finalText = stripMarkup(state.rawText); - return; - } - - if (state.typingControl === 'skip') { - while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { - frame.irisTypedText += frame.irisTypingSteps[frame.irisTypingStepIndex++].content; - } - state.rawText = frame.irisTypedText; - state.typingControl = null; - schedulePaint(util.target, state); - flushRenderIfDirty(util.target); - return; - } - - if (frame.irisTypingWaitUntil !== null) { - if (Date.now() < frame.irisTypingWaitUntil) { - util.yieldTick(); - return; - } - frame.irisTypingWaitUntil = null; - } - - while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { - const step = frame.irisTypingSteps[frame.irisTypingStepIndex++]; - frame.irisTypedText += step.content; - if (step.char === null) continue; - - state.rawText = frame.irisTypedText; - schedulePaint(util.target, state); - flushRenderIfDirty(util.target); - startTypingCharacterHat(util.target, step.char, frame.irisTypingCharIndex++); - - const delay = Math.max(0, Number(typingDelayForCharacter(state, step.char)) || 0); - if (delay > 0) { - frame.irisTypingWaitUntil = Date.now() + delay * 1000; - util.yieldTick(); - return; - } - } - - state.rawText = frame.irisTypedText; - schedulePaint(util.target, state); - } - - skipTyping(args, util) { - getState(util.target).typingControl = 'skip'; - } - - stopTyping(args, util) { - getState(util.target).typingControl = 'stop'; - } - - clearText(args, util) { + frame.irisWaitUntil = Date.now() + stage.waitAfter * 1000; + util.yieldTick(); + } + + addLine(args, util) { + const state = getState(util.target); + const text = Scratch.Cast.toString(args.TEXT); + const existingText = state.visible ? state.rawText : ''; + const linePrefix = existingText ? '\n' : ''; + if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); + state.revealToken++; + state.typingControl = null; + state.rawText = existingText + linePrefix + text; + state.finalText = stripMarkup(state.rawText); + state.visible = true; + schedulePaint(util.target, state); + } + + _typeText(args, util, appendLine) { + if (util && util.thread && !ThreadClass) ThreadClass = util.thread.constructor; + const state = getState(util.target); + const frame = util.stackFrame; + + if (frame.irisTypingSteps === undefined) { + const text = Scratch.Cast.toString(args.TEXT); + const existingText = appendLine && state.visible ? state.rawText : ''; + const linePrefix = appendLine && existingText ? '\n' : ''; + state.rawText = existingText + linePrefix; + state.finalText = stripMarkup(state.rawText + text); + state.visible = true; + if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); + state.revealToken++; + state.typingControl = null; + frame.irisTypingSteps = splitTypingSteps(text); + frame.irisTypingStepIndex = 0; + frame.irisTypingCharIndex = stripMarkup(existingText + linePrefix).length; + frame.irisTypedText = state.rawText; + frame.irisTypingToken = state.revealToken; + frame.irisTypingWaitUntil = null; + } + + if (state.revealToken !== frame.irisTypingToken) return; + + if (state.typingControl === 'stop') { + state.typingControl = null; + state.finalText = stripMarkup(state.rawText); + return; + } + + if (state.typingControl === 'skip') { + while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { + frame.irisTypedText += frame.irisTypingSteps[frame.irisTypingStepIndex++].content; + } + state.rawText = frame.irisTypedText; + state.typingControl = null; + schedulePaint(util.target, state); + flushRenderIfDirty(util.target); + return; + } + + if (frame.irisTypingWaitUntil !== null) { + if (Date.now() < frame.irisTypingWaitUntil) { + util.yieldTick(); + return; + } + frame.irisTypingWaitUntil = null; + } + + while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { + const step = frame.irisTypingSteps[frame.irisTypingStepIndex++]; + frame.irisTypedText += step.content; + if (step.char === null) continue; + + state.rawText = frame.irisTypedText; + schedulePaint(util.target, state); + flushRenderIfDirty(util.target); + startTypingCharacterHat(util.target, step.char, frame.irisTypingCharIndex++); + + const delay = Math.max(0, Number(typingDelayForCharacter(state, step.char)) || 0); + if (delay > 0) { + frame.irisTypingWaitUntil = Date.now() + delay * 1000; + util.yieldTick(); + return; + } + } + + state.rawText = frame.irisTypedText; + schedulePaint(util.target, state); + } + + typeText(args, util) { + this._typeText(args, util, false); + } + + typeNewLine(args, util) { + this._typeText(args, util, true); + } + + skipTyping(args, util) { + getState(util.target).typingControl = 'skip'; + } + + stopTyping(args, util) { + getState(util.target).typingControl = 'stop'; + } + + clearText(args, util) { clearTarget(util.target); } @@ -2756,81 +2826,81 @@ Enjoy!! :D schedulePaint(util.target, state); } - getFontMenuItems() { - return buildFontMenuItems(); - } - - getSpriteMenuItems() { - const items = [{ - text: 'myself', - value: '_myself_' - }]; - const names = new Set(); - for (const target of runtime.targets) { - if (target.isStage || target.isOriginal === false) continue; - const name = target.getName(); - if (!names.has(name)) { - names.add(name); - items.push({ - text: name, - value: name - }); - } - } - return items; - } - - exportSettings(args, util) { - return exportTextSettings(getState(util.target)); - } - - applyExportSettings(args, util) { - let settings; - try { - settings = JSON.parse(Scratch.Cast.toString(args.SETTINGS)); - } catch (e) { - return; - } - const sprite = Scratch.Cast.toString(args.SPRITE); - const target = sprite === '_myself_' ? - util.target : - runtime.getSpriteTargetByName(sprite); - if (!target || !applyTextSettings(getState(target), settings)) return; - schedulePaint(target, getState(target)); - } - - setAlign(args, util) { - const state = getState(util.target); - const align = Scratch.Cast.toString(args.ALIGN); - if (state.align === align) return; - state.align = align; - schedulePaint(util.target, state); - } - - setSmoothing(args, util) { - const state = getState(util.target); - const smoothing = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - if (state.smoothing === smoothing) return; - state.smoothing = smoothing; - schedulePaint(util.target, state); - } - - setTypingSpeed(args, util) { - const state = getState(util.target); - const group = Scratch.Cast.toString(args.GROUP); - if (!_hasOwn.call(state.typingSpeeds, group)) return; - state.typingSpeeds[group] = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); - } - - setCustomTypingSpeed(args, util) { - const state = getState(util.target); - const delay = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); - for (const char of Array.from(Scratch.Cast.toString(args.CHARS))) { - state.customTypingSpeeds[char] = delay; - } - } - - setMaxWidth(args, util) { + getFontMenuItems() { + return buildFontMenuItems(); + } + + getSpriteMenuItems() { + const items = [{ + text: 'myself', + value: '_myself_' + }]; + const names = new Set(); + for (const target of runtime.targets) { + if (target.isStage || target.isOriginal === false) continue; + const name = target.getName(); + if (!names.has(name)) { + names.add(name); + items.push({ + text: name, + value: name + }); + } + } + return items; + } + + exportSettings(args, util) { + return exportTextSettings(getState(util.target)); + } + + applyExportSettings(args, util) { + let settings; + try { + settings = JSON.parse(Scratch.Cast.toString(args.SETTINGS)); + } catch (e) { + return; + } + const sprite = Scratch.Cast.toString(args.SPRITE); + const target = sprite === '_myself_' ? + util.target : + runtime.getSpriteTargetByName(sprite); + if (!target || !applyTextSettings(getState(target), settings)) return; + schedulePaint(target, getState(target)); + } + + setAlign(args, util) { + const state = getState(util.target); + const align = Scratch.Cast.toString(args.ALIGN); + if (state.align === align) return; + state.align = align; + schedulePaint(util.target, state); + } + + setSmoothing(args, util) { + const state = getState(util.target); + const smoothing = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + if (state.smoothing === smoothing) return; + state.smoothing = smoothing; + schedulePaint(util.target, state); + } + + setTypingSpeed(args, util) { + const state = getState(util.target); + const group = Scratch.Cast.toString(args.GROUP); + if (!_hasOwn.call(state.typingSpeeds, group)) return; + state.typingSpeeds[group] = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); + } + + setCustomTypingSpeed(args, util) { + const state = getState(util.target); + const delay = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); + for (const char of Array.from(Scratch.Cast.toString(args.CHARS))) { + state.customTypingSpeeds[char] = delay; + } + } + + setMaxWidth(args, util) { const state = getState(util.target); const width = Scratch.Cast.toNumber(args.WIDTH); if (state.maxWidth === width) return; @@ -2864,98 +2934,98 @@ Enjoy!! :D schedulePaint(util.target, state); } - setLetterSpacing(args, util) { + setLetterSpacing(args, util) { const state = getState(util.target); const letterSpacing = Scratch.Cast.toNumber(args.SPACE); if (state.letterSpacing === letterSpacing) return; state.letterSpacing = letterSpacing; - schedulePaint(util.target, state); - } - - setTextShadowEnabled(args, util) { - const state = getState(util.target); - state.textShadow.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - schedulePaint(util.target, state); - } - - setTextShadowColor(args, util) { - const state = getState(util.target); - state.textShadow.color = Scratch.Cast.toString(args.COLOR); - schedulePaint(util.target, state); - } - - setTextShadowOpacity(args, util) { - const state = getState(util.target); - state.textShadow.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); - schedulePaint(util.target, state); - } - - setTextShadowBlur(args, util) { - const state = getState(util.target); - state.textShadow.blur = Math.max(0, Scratch.Cast.toNumber(args.BLUR)); - schedulePaint(util.target, state); - } - - setTextShadowOffset(args, util) { - const state = getState(util.target); - state.textShadow.offsetX = Scratch.Cast.toNumber(args.X); - state.textShadow.offsetY = Scratch.Cast.toNumber(args.Y); - schedulePaint(util.target, state); - } - - setTextBackgroundEnabled(args, util) { - const state = getState(util.target); - state.textBackground.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - schedulePaint(util.target, state); - } - - setTextBackgroundColor(args, util) { - const state = getState(util.target); - state.textBackground.color = Scratch.Cast.toString(args.COLOR); - schedulePaint(util.target, state); - } - - setTextBackgroundOpacity(args, util) { - const state = getState(util.target); - state.textBackground.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); - schedulePaint(util.target, state); - } - - setTextBackgroundRadius(args, util) { - const state = getState(util.target); - state.textBackground.radius = Math.max(0, Scratch.Cast.toNumber(args.RADIUS)); - schedulePaint(util.target, state); - } - - setTextBackgroundPadding(args, util) { - const state = getState(util.target); - state.textBackground.padding = Math.max(0, Scratch.Cast.toNumber(args.PADDING)); - schedulePaint(util.target, state); - } - - setTextBorderEnabled(args, util) { - const state = getState(util.target); - state.textBorder.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - schedulePaint(util.target, state); - } - - setTextBorderColor(args, util) { - const state = getState(util.target); - state.textBorder.color = Scratch.Cast.toString(args.COLOR); - schedulePaint(util.target, state); - } - - setTextBorderOpacity(args, util) { - const state = getState(util.target); - state.textBorder.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); - schedulePaint(util.target, state); - } - - setTextBorderSize(args, util) { - const state = getState(util.target); - state.textBorder.size = Math.max(0, Scratch.Cast.toNumber(args.SIZE)); - schedulePaint(util.target, state); - } + schedulePaint(util.target, state); + } + + setTextShadowEnabled(args, util) { + const state = getState(util.target); + state.textShadow.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextShadowColor(args, util) { + const state = getState(util.target); + state.textShadow.color = Scratch.Cast.toString(args.COLOR); + schedulePaint(util.target, state); + } + + setTextShadowOpacity(args, util) { + const state = getState(util.target); + state.textShadow.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextShadowBlur(args, util) { + const state = getState(util.target); + state.textShadow.blur = Math.max(0, Scratch.Cast.toNumber(args.BLUR)); + schedulePaint(util.target, state); + } + + setTextShadowOffset(args, util) { + const state = getState(util.target); + state.textShadow.offsetX = Scratch.Cast.toNumber(args.X); + state.textShadow.offsetY = Scratch.Cast.toNumber(args.Y); + schedulePaint(util.target, state); + } + + setTextBackgroundEnabled(args, util) { + const state = getState(util.target); + state.textBackground.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextBackgroundColor(args, util) { + const state = getState(util.target); + state.textBackground.color = Scratch.Cast.toString(args.COLOR); + schedulePaint(util.target, state); + } + + setTextBackgroundOpacity(args, util) { + const state = getState(util.target); + state.textBackground.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextBackgroundRadius(args, util) { + const state = getState(util.target); + state.textBackground.radius = Math.max(0, Scratch.Cast.toNumber(args.RADIUS)); + schedulePaint(util.target, state); + } + + setTextBackgroundPadding(args, util) { + const state = getState(util.target); + state.textBackground.padding = Math.max(0, Scratch.Cast.toNumber(args.PADDING)); + schedulePaint(util.target, state); + } + + setTextBorderEnabled(args, util) { + const state = getState(util.target); + state.textBorder.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextBorderColor(args, util) { + const state = getState(util.target); + state.textBorder.color = Scratch.Cast.toString(args.COLOR); + schedulePaint(util.target, state); + } + + setTextBorderOpacity(args, util) { + const state = getState(util.target); + state.textBorder.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextBorderSize(args, util) { + const state = getState(util.target); + state.textBorder.size = Math.max(0, Scratch.Cast.toNumber(args.SIZE)); + schedulePaint(util.target, state); + } scheduleTagBlocks(args, util) { const name = util.target.id; @@ -3002,15 +3072,20 @@ Enjoy!! :D charScripts[name][tag] = newEntry; } - currentCharValue(args, util) { - const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; - return cur ? cur.char : ''; - } - - currentCharIndex(args, util) { - const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; - return cur ? cur.index + 1 : 0; - } + currentCharValue(args, util) { + const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; + return cur ? cur.char : ''; + } + + currentCharIndex(args, util) { + const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; + return cur ? cur.index + 1 : 0; + } + + setResetCharTransformsOnText(args, util) { + const state = getState(util.target); + state.resetCharTransformsOnText = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + } setCharPos(args, util) { const state = getState(util.target); @@ -3081,59 +3156,59 @@ Enjoy!! :D } } - resetAllCharTransforms(args, util) { - const state = getState(util.target); - if (Object.keys(state.charOverrides).length) { + resetAllCharTransforms(args, util) { + const state = getState(util.target); + if (Object.keys(state.charOverrides).length) { state.charOverrides = {}; state.charOverridesVersion++; schedulePaint(util.target, state); - } - } - - getCharacterStyle(args, util) { - const state = getState(util.target); - flushRenderIfDirty(util.target); - const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - const box = state.charBoxes[idx]; - return box && box.style ? JSON.stringify(box.style) : ''; - } - - setCharacterStyle(args, util) { - const state = getState(util.target); - const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - flushRenderIfDirty(util.target); - const box = state.charBoxes[idx]; - if (!box || !box.style) return; - - let input; - try { - input = JSON.parse(Scratch.Cast.toString(args.STYLE)); - } catch (e) { - return; - } - const style = characterStyleFromJSON(input, box.style); - if (!style) return; - state.charStyleOverrides[idx] = style; - state.charStyleOverridesVersion++; - state.shapeKey = null; - state.layout = null; - state.paintFingerprint = null; - schedulePaint(util.target, state); - } - - resetCharacterStyle(args, util) { - const state = getState(util.target); - const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - if (!state.charStyleOverrides[idx]) return; - delete state.charStyleOverrides[idx]; - state.charStyleOverridesVersion++; - state.shapeKey = null; - state.layout = null; - state.paintFingerprint = null; - schedulePaint(util.target, state); - } - - getCharX(args, util) { + } + } + + getCharacterStyle(args, util) { + const state = getState(util.target); + flushRenderIfDirty(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const box = state.charBoxes[idx]; + return box && box.style ? JSON.stringify(box.style) : ''; + } + + setCharacterStyle(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + flushRenderIfDirty(util.target); + const box = state.charBoxes[idx]; + if (!box || !box.style) return; + + let input; + try { + input = JSON.parse(Scratch.Cast.toString(args.STYLE)); + } catch (e) { + return; + } + const style = characterStyleFromJSON(input, box.style); + if (!style) return; + state.charStyleOverrides[idx] = style; + state.charStyleOverridesVersion++; + state.shapeKey = null; + state.layout = null; + state.paintFingerprint = null; + schedulePaint(util.target, state); + } + + resetCharacterStyle(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + if (!state.charStyleOverrides[idx]) return; + delete state.charStyleOverrides[idx]; + state.charStyleOverridesVersion++; + state.shapeKey = null; + state.layout = null; + state.paintFingerprint = null; + schedulePaint(util.target, state); + } + + getCharX(args, util) { const state = getState(util.target); flushRenderIfDirty(util.target); const idx = Scratch.Cast.toNumber(args.INDEX) - 1; @@ -3216,4 +3291,4 @@ Enjoy!! :D } Scratch.extensions.register(new IrisText()); -})(Scratch); \ No newline at end of file +})(Scratch); From 47362c6057a10e6d28cd4900c5a70a2a13b808be Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 18 Aug 2026 01:39:43 -0300 Subject: [PATCH 06/21] HOW tf did i miss this --- static/extensions/Gen1x/iris-text.js | 1 + 1 file changed, 1 insertion(+) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 27040d832..226f81ef0 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -1138,6 +1138,7 @@ Enjoy!! :D const letterSpacing = state.letterSpacing || 0; for (let li = 0; li < lines.length; li++) { + const line = lines[li]; const runs = lineRuns[li]; const lineW = lineWidths[li]; const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && From c62b12843ad196c7258baa827efbbbd54e425ad0 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 18 Aug 2026 01:48:00 -0300 Subject: [PATCH 07/21] i FORGOT TO CHANGE THE ID --- static/extensions/Gen1x/iris-text.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 226f81ef0..2bfc3e417 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -1849,7 +1849,7 @@ Enjoy!! :D getInfo() { return { - id: 'irisText', + id: 'g1nxIrisText', name: 'Iris Text', color1: COLOR_PRIMARY, color2: COLOR_SECONDARY, From a0f5dce8376531113ba13d630c703259380b183f Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 18 Aug 2026 17:36:01 -0300 Subject: [PATCH 08/21] Fix all issues mentioned by Steve... hopefully --- static/extensions/Gen1x/iris-text.js | 3202 ++++++++++++-------------- 1 file changed, 1462 insertions(+), 1740 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 2bfc3e417..5dddf2aba 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -165,7 +165,7 @@ Enjoy!! :D const TAG_RE = /\[(\/?)([a-z0-9_-]+)(?:=([^\]]+))?\]/gi; const TAG_RE_STRIP = /\[(\/?)([a-z0-9_-]+)(?:=([^\]]+))?\]/gi; - function parseRichText(text, base) { + function parseRichText(text, base) { const stack = [Object.assign({ customTags: [] }, base)]; @@ -245,55 +245,54 @@ Enjoy!! :D } i = match.index + match[0].length; } - return out; - } - - function characterStyleSnapshot(style) { - return { - color: style.color, - bold: style.bold, - italic: style.italic, - underline: style.underline, - strike: style.strike, - size: style.size, - font: style.font, - tags: Array.isArray(style.tags) ? style.tags.slice() : [] - }; - } - - function applyCharacterStyleOverrides(chars, overrides) { - for (const index in overrides) { - const char = chars[index]; - const override = overrides[index]; - if (!char || !override) continue; - char.color = override.color; - char.bold = override.bold; - char.italic = override.italic; - char.underline = override.underline; - char.strike = override.strike; - char.size = override.size; - char.font = override.font; - char.tags = override.tags.slice(); - } - } - - function characterStyleFromJSON(value, base) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const style = characterStyleSnapshot(base); - if (typeof value.color === 'string') style.color = value.color; - if (typeof value.bold === 'boolean') style.bold = value.bold; - if (typeof value.italic === 'boolean') style.italic = value.italic; - if (typeof value.underline === 'boolean') style.underline = value.underline; - if (typeof value.strike === 'boolean') style.strike = value.strike; - if (Number.isFinite(value.size)) style.size = Math.max(1, value.size); - if (typeof value.font === 'string') style.font = value.font; - if (Array.isArray(value.tags) && value.tags.every(tag => typeof tag === 'string')) { - style.tags = value.tags.slice(); - } - return style; - } - - function stripMarkup(text) { + return out; + } + + function characterStyleSnapshot(style) { + return { + color: style.color, + bold: style.bold, + italic: style.italic, + underline: style.underline, + strike: style.strike, + size: style.size, + font: style.font, + tags: Array.isArray(style.tags) ? style.tags.slice() : [] + }; + } + + function applyCharacterStyleOverrides(chars, overrides) { + for (const index in overrides) { + const char = chars[index]; + const override = overrides[index]; + if (!char || !override) continue; + char.color = override.color; + char.bold = override.bold; + char.italic = override.italic; + char.underline = override.underline; + char.strike = override.strike; + char.size = override.size; + char.font = override.font; + } + } + + function characterStyleFromJSON(value, base) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const style = characterStyleSnapshot(base); + if (typeof value.color === 'string') style.color = value.color; + if (typeof value.bold === 'boolean') style.bold = value.bold; + if (typeof value.italic === 'boolean') style.italic = value.italic; + if (typeof value.underline === 'boolean') style.underline = value.underline; + if (typeof value.strike === 'boolean') style.strike = value.strike; + if (Number.isFinite(value.size)) style.size = Math.max(1, value.size); + if (typeof value.font === 'string') style.font = value.font; + if (Array.isArray(value.tags) && value.tags.every(tag => typeof tag === 'string')) { + style.tags = value.tags.slice(); + } + return style; + } + + function stripMarkup(text) { TAG_RE_STRIP.lastIndex = 0; return text.replace(TAG_RE_STRIP, ''); } @@ -335,53 +334,53 @@ Enjoy!! :D return stages; } - function defaultState() { - return { + function defaultState() { + return { rawText: '', finalText: '', visible: false, baseStyle: defaultBaseStyle(), - align: 'center', - smoothing: true, - letterSpacing: 0, - lineSpacing: 1.2, - typingSpeeds: { - default: 0.05, - symbols: 0.05, - punctuation: 0.15, - numbers: 0.05 - }, - customTypingSpeeds: {}, - maxWidth: null, - maxHeight: null, - textShadow: { - enabled: false, - color: '#000000', - opacity: 50, - blur: 8, - offsetX: 4, - offsetY: 4 - }, - textBackground: { - enabled: false, - color: '#000000', - opacity: 50, - radius: 8, - padding: 8 - }, - textBorder: { - enabled: false, - color: '#000000', - opacity: 100, - size: 2 - }, - charOverrides: {}, - charOverridesVersion: 0, - resetCharTransformsOnText: true, - charStyleOverrides: {}, - charStyleOverridesVersion: 0, - typingControl: null, - skinId: null, + align: 'center', + smoothing: true, + letterSpacing: 0, + lineSpacing: 1.2, + typingSpeeds: { + default: 0.05, + symbols: 0.05, + punctuation: 0.15, + numbers: 0.05 + }, + customTypingSpeeds: {}, + maxWidth: null, + maxHeight: null, + textShadow: { + enabled: false, + color: '#000000', + opacity: 50, + blur: 8, + offsetX: 4, + offsetY: 4 + }, + textBackground: { + enabled: false, + color: '#000000', + opacity: 50, + radius: 8, + padding: 8 + }, + textBorder: { + enabled: false, + color: '#000000', + opacity: 100, + size: 2 + }, + charOverrides: {}, + charOverridesVersion: 0, + resetCharTransformsOnText: true, + charStyleOverrides: {}, + charStyleOverridesVersion: 0, + typingControl: null, + skinId: null, drawableWidth: 0, drawableHeight: 0, charBoxes: [], @@ -409,168 +408,168 @@ Enjoy!! :D base.strike ? 1 : 0, base.size, base.font, - state.letterSpacing, - state.lineSpacing, - state.maxWidth || 0, - state.maxHeight || 0, - state.charStyleOverridesVersion - ].join('\u0001'); - } - - function getState(target) { - let state = target.getCustomState(STATE_KEY); - if (!state) { - state = defaultState(); - target.setCustomState(STATE_KEY, state); - } else { - const defaults = defaultState(); - state.textShadow = Object.assign(defaults.textShadow, state.textShadow || {}); - state.textBackground = Object.assign(defaults.textBackground, state.textBackground || {}); - state.textBorder = Object.assign(defaults.textBorder, state.textBorder || {}); - state.typingSpeeds = Object.assign(defaults.typingSpeeds, state.typingSpeeds || {}); - state.customTypingSpeeds = Object.assign({}, state.customTypingSpeeds || {}); - if (typeof state.smoothing !== 'boolean') state.smoothing = defaults.smoothing; - state.charStyleOverrides = Object.assign({}, state.charStyleOverrides || {}); - if (typeof state.resetCharTransformsOnText !== 'boolean') state.resetCharTransformsOnText = defaults.resetCharTransformsOnText; - if (typeof state.charStyleOverridesVersion !== 'number') state.charStyleOverridesVersion = 0; - if (state.typingControl !== 'skip' && state.typingControl !== 'stop') state.typingControl = null; - } - return state; - } - - const TYPING_PUNCTUATION = '.,!?;:\'"…'; - const TYPING_SYMBOLS = '@#$%^&*+=<>/\\|~`_-()[]{}'; - - function typingGroupForCharacter(char) { - if (/^[0-9]$/.test(char)) return 'numbers'; - if (TYPING_PUNCTUATION.includes(char)) return 'punctuation'; - if (TYPING_SYMBOLS.includes(char)) return 'symbols'; - return 'default'; - } - - function typingDelayForCharacter(state, char) { - if (_hasOwn.call(state.customTypingSpeeds, char)) return state.customTypingSpeeds[char]; - return state.typingSpeeds[typingGroupForCharacter(char)]; - } - - function splitTypingSteps(text) { - const steps = []; - TAG_RE.lastIndex = 0; - let lastIndex = 0; - let match; - - const addCharacters = value => { - for (const char of Array.from(value)) { - steps.push({ - content: char, - char - }); - } - }; - - while ((match = TAG_RE.exec(text)) !== null) { - addCharacters(text.slice(lastIndex, match.index)); - steps.push({ - content: match[0], - char: null - }); - lastIndex = match.index + match[0].length; - } - addCharacters(text.slice(lastIndex)); - return steps; - } - - function exportTextSettings(state) { - return JSON.stringify({ - version: 1, - baseStyle: { - color: state.baseStyle.color, - bold: state.baseStyle.bold, - italic: state.baseStyle.italic, - underline: state.baseStyle.underline, - strike: state.baseStyle.strike, - size: state.baseStyle.size, - font: state.baseStyle.font - }, - align: state.align, - smoothing: state.smoothing, - resetCharTransformsOnText: state.resetCharTransformsOnText, - letterSpacing: state.letterSpacing, - lineSpacing: state.lineSpacing, - typingSpeeds: Object.assign({}, state.typingSpeeds), - customTypingSpeeds: Object.assign({}, state.customTypingSpeeds), - maxWidth: state.maxWidth, - maxHeight: state.maxHeight, - textShadow: Object.assign({}, state.textShadow), - textBackground: Object.assign({}, state.textBackground), - textBorder: Object.assign({}, state.textBorder) - }); - } - - function applyTextSettings(state, settings) { - if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false; - - const base = settings.baseStyle; - if (base && typeof base === 'object' && !Array.isArray(base)) { - if (typeof base.color === 'string') state.baseStyle.color = base.color; - if (typeof base.bold === 'boolean') state.baseStyle.bold = base.bold; - if (typeof base.italic === 'boolean') state.baseStyle.italic = base.italic; - if (typeof base.underline === 'boolean') state.baseStyle.underline = base.underline; - if (typeof base.strike === 'boolean') state.baseStyle.strike = base.strike; - if (Number.isFinite(base.size)) state.baseStyle.size = Math.max(1, base.size); - if (typeof base.font === 'string') state.baseStyle.font = base.font; - } - if (settings.align === 'left' || settings.align === 'center' || settings.align === 'right' || settings.align === 'justify') { - state.align = settings.align; - } - if (typeof settings.smoothing === 'boolean') state.smoothing = settings.smoothing; - if (typeof settings.resetCharTransformsOnText === 'boolean') state.resetCharTransformsOnText = settings.resetCharTransformsOnText; - if (Number.isFinite(settings.letterSpacing)) state.letterSpacing = settings.letterSpacing; - if (Number.isFinite(settings.lineSpacing)) state.lineSpacing = Math.max(0, settings.lineSpacing); - if (settings.typingSpeeds && typeof settings.typingSpeeds === 'object' && !Array.isArray(settings.typingSpeeds)) { - for (const group of ['default', 'symbols', 'punctuation', 'numbers']) { - if (Number.isFinite(settings.typingSpeeds[group])) { - state.typingSpeeds[group] = Math.max(0, settings.typingSpeeds[group]); - } - } - } - if (settings.customTypingSpeeds && typeof settings.customTypingSpeeds === 'object' && !Array.isArray(settings.customTypingSpeeds)) { - state.customTypingSpeeds = {}; - for (const char in settings.customTypingSpeeds) { - if (Number.isFinite(settings.customTypingSpeeds[char])) { - state.customTypingSpeeds[char] = Math.max(0, settings.customTypingSpeeds[char]); - } - } - } - if (settings.maxWidth === null || Number.isFinite(settings.maxWidth)) state.maxWidth = settings.maxWidth; - if (settings.maxHeight === null || Number.isFinite(settings.maxHeight)) state.maxHeight = settings.maxHeight; - - const copySettings = (source, target, keys) => { - if (!source || typeof source !== 'object' || Array.isArray(source)) return; - for (const key of keys) { - if (typeof target[key] === 'boolean' && typeof source[key] === 'boolean') { - target[key] = source[key]; - } else if (typeof target[key] === 'number' && Number.isFinite(source[key])) { - target[key] = source[key]; - } else if (typeof target[key] === 'string' && typeof source[key] === 'string') { - target[key] = source[key]; - } - } - }; - - copySettings(settings.textShadow, state.textShadow, ['enabled', 'color', 'opacity', 'blur', 'offsetX', 'offsetY']); - copySettings(settings.textBackground, state.textBackground, ['enabled', 'color', 'opacity', 'radius', 'padding']); - copySettings(settings.textBorder, state.textBorder, ['enabled', 'color', 'opacity', 'size']); - state.shapeKey = null; - state.layoutKey = null; - state.layout = null; - state.paintDirty = true; - state.paintFingerprint = null; - return true; + state.letterSpacing, + state.lineSpacing, + state.maxWidth || 0, + state.maxHeight || 0, + state.charStyleOverridesVersion + ].join('\u0001'); } - function getCharOverride(state, index) { - if (!state.charOverrides[index]) { + function getState(target) { + let state = target.getCustomState(STATE_KEY); + if (!state) { + state = defaultState(); + target.setCustomState(STATE_KEY, state); + } else { + const defaults = defaultState(); + state.textShadow = Object.assign(defaults.textShadow, state.textShadow || {}); + state.textBackground = Object.assign(defaults.textBackground, state.textBackground || {}); + state.textBorder = Object.assign(defaults.textBorder, state.textBorder || {}); + state.typingSpeeds = Object.assign(defaults.typingSpeeds, state.typingSpeeds || {}); + state.customTypingSpeeds = Object.assign({}, state.customTypingSpeeds || {}); + if (typeof state.smoothing !== 'boolean') state.smoothing = defaults.smoothing; + state.charStyleOverrides = Object.assign({}, state.charStyleOverrides || {}); + if (typeof state.resetCharTransformsOnText !== 'boolean') state.resetCharTransformsOnText = defaults.resetCharTransformsOnText; + if (typeof state.charStyleOverridesVersion !== 'number') state.charStyleOverridesVersion = 0; + if (state.typingControl !== 'skip' && state.typingControl !== 'stop') state.typingControl = null; + } + return state; + } + + const TYPING_PUNCTUATION = '.,!?;:\'"…'; + const TYPING_SYMBOLS = '@#$%^&*+=<>/\\|~`_-()[]{}'; + + function typingGroupForCharacter(char) { + if (/^[0-9]$/.test(char)) return 'numbers'; + if (TYPING_PUNCTUATION.includes(char)) return 'punctuation'; + if (TYPING_SYMBOLS.includes(char)) return 'symbols'; + return 'default'; + } + + function typingDelayForCharacter(state, char) { + if (_hasOwn.call(state.customTypingSpeeds, char)) return state.customTypingSpeeds[char]; + return state.typingSpeeds[typingGroupForCharacter(char)]; + } + + function splitTypingSteps(text) { + const steps = []; + TAG_RE.lastIndex = 0; + let lastIndex = 0; + let match; + + const addCharacters = value => { + for (const char of Array.from(value)) { + steps.push({ + content: char, + char + }); + } + }; + + while ((match = TAG_RE.exec(text)) !== null) { + addCharacters(text.slice(lastIndex, match.index)); + steps.push({ + content: match[0], + char: null + }); + lastIndex = match.index + match[0].length; + } + addCharacters(text.slice(lastIndex)); + return steps; + } + + function exportTextSettings(state) { + return JSON.stringify({ + version: 1, + baseStyle: { + color: state.baseStyle.color, + bold: state.baseStyle.bold, + italic: state.baseStyle.italic, + underline: state.baseStyle.underline, + strike: state.baseStyle.strike, + size: state.baseStyle.size, + font: state.baseStyle.font + }, + align: state.align, + smoothing: state.smoothing, + resetCharTransformsOnText: state.resetCharTransformsOnText, + letterSpacing: state.letterSpacing, + lineSpacing: state.lineSpacing, + typingSpeeds: Object.assign({}, state.typingSpeeds), + customTypingSpeeds: Object.assign({}, state.customTypingSpeeds), + maxWidth: state.maxWidth, + maxHeight: state.maxHeight, + textShadow: Object.assign({}, state.textShadow), + textBackground: Object.assign({}, state.textBackground), + textBorder: Object.assign({}, state.textBorder) + }); + } + + function applyTextSettings(state, settings) { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false; + + const base = settings.baseStyle; + if (base && typeof base === 'object' && !Array.isArray(base)) { + if (typeof base.color === 'string') state.baseStyle.color = base.color; + if (typeof base.bold === 'boolean') state.baseStyle.bold = base.bold; + if (typeof base.italic === 'boolean') state.baseStyle.italic = base.italic; + if (typeof base.underline === 'boolean') state.baseStyle.underline = base.underline; + if (typeof base.strike === 'boolean') state.baseStyle.strike = base.strike; + if (Number.isFinite(base.size)) state.baseStyle.size = Math.max(1, base.size); + if (typeof base.font === 'string') state.baseStyle.font = base.font; + } + if (settings.align === 'left' || settings.align === 'center' || settings.align === 'right' || settings.align === 'justify') { + state.align = settings.align; + } + if (typeof settings.smoothing === 'boolean') state.smoothing = settings.smoothing; + if (typeof settings.resetCharTransformsOnText === 'boolean') state.resetCharTransformsOnText = settings.resetCharTransformsOnText; + if (Number.isFinite(settings.letterSpacing)) state.letterSpacing = settings.letterSpacing; + if (Number.isFinite(settings.lineSpacing)) state.lineSpacing = Math.max(0, settings.lineSpacing); + if (settings.typingSpeeds && typeof settings.typingSpeeds === 'object' && !Array.isArray(settings.typingSpeeds)) { + for (const group of ['default', 'symbols', 'punctuation', 'numbers']) { + if (Number.isFinite(settings.typingSpeeds[group])) { + state.typingSpeeds[group] = Math.max(0, settings.typingSpeeds[group]); + } + } + } + if (settings.customTypingSpeeds && typeof settings.customTypingSpeeds === 'object' && !Array.isArray(settings.customTypingSpeeds)) { + state.customTypingSpeeds = {}; + for (const char in settings.customTypingSpeeds) { + if (Number.isFinite(settings.customTypingSpeeds[char])) { + state.customTypingSpeeds[char] = Math.max(0, settings.customTypingSpeeds[char]); + } + } + } + if (settings.maxWidth === null || Number.isFinite(settings.maxWidth)) state.maxWidth = settings.maxWidth; + if (settings.maxHeight === null || Number.isFinite(settings.maxHeight)) state.maxHeight = settings.maxHeight; + + const copySettings = (source, target, keys) => { + if (!source || typeof source !== 'object' || Array.isArray(source)) return; + for (const key of keys) { + if (typeof target[key] === 'boolean' && typeof source[key] === 'boolean') { + target[key] = source[key]; + } else if (typeof target[key] === 'number' && Number.isFinite(source[key])) { + target[key] = source[key]; + } else if (typeof target[key] === 'string' && typeof source[key] === 'string') { + target[key] = source[key]; + } + } + }; + + copySettings(settings.textShadow, state.textShadow, ['enabled', 'color', 'opacity', 'blur', 'offsetX', 'offsetY']); + copySettings(settings.textBackground, state.textBackground, ['enabled', 'color', 'opacity', 'radius', 'padding']); + copySettings(settings.textBorder, state.textBorder, ['enabled', 'color', 'opacity', 'size']); + state.shapeKey = null; + state.layoutKey = null; + state.layout = null; + state.paintDirty = true; + state.paintFingerprint = null; + return true; + } + + function getCharOverride(state, index) { + if (!state.charOverrides[index]) { state.charOverrides[index] = { x: 0, y: 0, @@ -579,14 +578,14 @@ Enjoy!! :D }; state.charOverridesVersion++; } - return state.charOverrides[index]; - } - - function resetCharacterTransformsState(state) { - if (!Object.keys(state.charOverrides).length) return; - state.charOverrides = {}; - state.charOverridesVersion++; - } + return state.charOverrides[index]; + } + + function resetCharacterTransformsState(state) { + if (!Object.keys(state.charOverrides).length) return; + state.charOverrides = {}; + state.charOverridesVersion++; + } const SVG_NS = 'http://www.w3.org/2000/svg'; let measureSvg = null; @@ -614,12 +613,15 @@ Enjoy!! :D glyphMeasureCtx.textRendering = 'optimizeLegibility'; } catch (e) {} - function glyphCacheKey(char, fontFamily, size, weight, style, color) { + const GLYPH_CACHE_LIMIT = 2000; + const TINT_CACHE_LIMIT = 512; + + function glyphCacheKey(char, fontFamily, size, weight, style) { return char + '\u0001' + fontFamily + '\u0001' + size + '\u0001' + - weight + '\u0001' + style + '\u0001' + color; + weight + '\u0001' + style; } - function rasterizeGlyph(char, fontFamily, size, weight, style, color) { + function rasterizeGlyph(char, fontFamily, size, weight, style) { const px = Math.max(1, Math.round(size * GLYPH_OVERSAMPLE)); const pad = Math.max(2, Math.round(size * GLYPH_PADDING_EM * GLYPH_OVERSAMPLE)); const fontStr = `${style} ${weight} ${px}px ${fontFamily}`; @@ -650,7 +652,7 @@ Enjoy!! :D const ctx = canvas.getContext('2d'); ctx.font = fontStr; ctx.textBaseline = 'alphabetic'; - ctx.fillStyle = color; + ctx.fillStyle = '#ffffff'; try { ctx.textRendering = 'optimizeLegibility'; } catch (e) {} @@ -660,6 +662,7 @@ Enjoy!! :D return { canvas, + tinted: new Map(), advance: advance / GLYPH_OVERSAMPLE, baselineOriginXEm: baselineX / GLYPH_OVERSAMPLE, baselineOriginYEm: baselineY / GLYPH_OVERSAMPLE, @@ -668,61 +671,97 @@ Enjoy!! :D }; } - function getGlyphBitmap(char, fontFamily, size, weight, style, color) { - const key = glyphCacheKey(char, fontFamily, size, weight, style, color); + function getGlyphShape(char, fontFamily, size, weight, style) { + const key = glyphCacheKey(char, fontFamily, size, weight, style); let entry = glyphCache.get(key); if (!entry) { - entry = rasterizeGlyph(char, fontFamily, size, weight, style, color); + if (glyphCache.size >= GLYPH_CACHE_LIMIT) { + glyphCache.delete(glyphCache.keys().next().value); + } + entry = rasterizeGlyph(char, fontFamily, size, weight, style); glyphCache.set(key, entry); } return entry; } - function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { - const stroke = (x1, x2) => { - if (x2 <= x1) return; - ctx.beginPath(); - ctx.moveTo(x1, y); - ctx.lineTo(x2, y); - ctx.stroke(); - }; + function tintGlyph(shape, color) { + let tintedCanvas = shape.tinted.get(color); + if (tintedCanvas) return tintedCanvas; - try { - const image = glyph.canvas.getContext('2d').getImageData(0, 0, glyph.canvas.width, glyph.canvas.height); - const rowStart = Math.max(0, Math.floor(y - glyphY - lineWidth / 2)); - const rowEnd = Math.min(glyph.canvas.height - 1, Math.ceil(y - glyphY + lineWidth / 2)); - const columnStart = Math.max(0, Math.floor(startX - glyphX)); - const columnEnd = Math.min(glyph.canvas.width - 1, Math.ceil(endX - glyphX)); - const ink = new Uint8Array(glyph.canvas.width); - - for (let py = rowStart; py <= rowEnd; py++) { - for (let px = columnStart; px <= columnEnd; px++) { - if (image.data[(py * glyph.canvas.width + px) * 4 + 3] > 16) ink[px] = 1; - } - } + tintedCanvas = document.createElement('canvas'); + tintedCanvas.width = shape.canvas.width; + tintedCanvas.height = shape.canvas.height; + const tctx = tintedCanvas.getContext('2d'); + tctx.fillStyle = color; + tctx.fillRect(0, 0, tintedCanvas.width, tintedCanvas.height); + tctx.globalCompositeOperation = 'destination-in'; + tctx.drawImage(shape.canvas, 0, 0); - const padding = Math.max(1, Math.ceil(lineWidth / 2) + 1); - let segmentStart = startX; - let px = columnStart; - while (px <= columnEnd) { - if (!ink[px]) { - px++; - continue; - } - const inkStart = px; - while (px <= columnEnd && ink[px]) px++; - const skipStart = Math.max(startX, glyphX + inkStart - padding); - const skipEnd = Math.min(endX, glyphX + px + padding); - stroke(segmentStart, skipStart); - segmentStart = Math.max(segmentStart, skipEnd); - } - stroke(segmentStart, endX); - } catch (e) { - stroke(startX, endX); + if (shape.tinted.size >= TINT_CACHE_LIMIT) { + shape.tinted.delete(shape.tinted.keys().next().value); } + shape.tinted.set(color, tintedCanvas); + return tintedCanvas; } - function invalidateGlyphCacheForFamily(fontFamily) { + function getGlyphBitmap(char, fontFamily, size, weight, style, color) { + const shape = getGlyphShape(char, fontFamily, size, weight, style); + const canvas = tintGlyph(shape, color); + return { + canvas, + advance: shape.advance, + baselineOriginXEm: shape.baselineOriginXEm, + baselineOriginYEm: shape.baselineOriginYEm, + fontAscentEm: shape.fontAscentEm, + fontDescentEm: shape.fontDescentEm + }; + } + + function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { + const stroke = (x1, x2) => { + if (x2 <= x1) return; + ctx.beginPath(); + ctx.moveTo(x1, y); + ctx.lineTo(x2, y); + ctx.stroke(); + }; + + try { + const image = glyph.canvas.getContext('2d').getImageData(0, 0, glyph.canvas.width, glyph.canvas.height); + const rowStart = Math.max(0, Math.floor(y - glyphY - lineWidth / 2)); + const rowEnd = Math.min(glyph.canvas.height - 1, Math.ceil(y - glyphY + lineWidth / 2)); + const columnStart = Math.max(0, Math.floor(startX - glyphX)); + const columnEnd = Math.min(glyph.canvas.width - 1, Math.ceil(endX - glyphX)); + const ink = new Uint8Array(glyph.canvas.width); + + for (let py = rowStart; py <= rowEnd; py++) { + for (let px = columnStart; px <= columnEnd; px++) { + if (image.data[(py * glyph.canvas.width + px) * 4 + 3] > 16) ink[px] = 1; + } + } + + const padding = Math.max(1, Math.ceil(lineWidth / 2) + 1); + let segmentStart = startX; + let px = columnStart; + while (px <= columnEnd) { + if (!ink[px]) { + px++; + continue; + } + const inkStart = px; + while (px <= columnEnd && ink[px]) px++; + const skipStart = Math.max(startX, glyphX + inkStart - padding); + const skipEnd = Math.min(endX, glyphX + px + padding); + stroke(segmentStart, skipStart); + segmentStart = Math.max(segmentStart, skipEnd); + } + stroke(segmentStart, endX); + } catch (e) { + stroke(startX, endX); + } + } + + function invalidateGlyphCacheForFamily(fontFamily) { for (const key of glyphCache.keys()) { if (key.indexOf('\u0001' + fontFamily + '\u0001') !== -1) glyphCache.delete(key); } @@ -927,24 +966,24 @@ Enjoy!! :D for (let li = 0; li < lines.length; li++) { const line = lines[li]; - const runs = splitShapingRuns(line, state.charOverrides, globalIndex); - - let runStartX = 0; - for (const run of runs) { - run.fontStyle = svgFontStyle(run.style); - run.runStartX = runStartX; - run.positions = []; - let charX = 0; - for (let i = 0; i < run.chars.length; i++) { - const w = run.chars[i]._width; - run.positions.push({ - shapedX: charX, - advance: w - }); - addTagIndex(charsByTag, run.startIndex + i, run.chars[i].tags); - charX += w; - runStartX += w; - } + const runs = splitShapingRuns(line, state.charOverrides, globalIndex); + + let runStartX = 0; + for (const run of runs) { + run.fontStyle = svgFontStyle(run.style); + run.runStartX = runStartX; + run.positions = []; + let charX = 0; + for (let i = 0; i < run.chars.length; i++) { + const w = run.chars[i]._width; + run.positions.push({ + shapedX: charX, + advance: w + }); + addTagIndex(charsByTag, run.startIndex + i, run.chars[i].tags); + charX += w; + runStartX += w; + } run.runWidth = runStartX - run.runStartX; } @@ -997,8 +1036,7 @@ Enjoy!! :D return h; } - const pendingRenderTargets = new Set(); - const pendingCharacterThreadStarts = new Set(); + const pendingRenderTargets = new Set(); let renderFlushScheduled = false; function requestRenderFlush() { @@ -1012,49 +1050,34 @@ Enjoy!! :D requestRenderFlush(); } - function flushPendingRenders() { - renderFlushScheduled = false; - const renderTargets = new Set(pendingRenderTargets); - for (const target of pendingCharacterThreadStarts) renderTargets.add(target); - for (const target of renderTargets) { - const needsRender = pendingRenderTargets.delete(target); - const startsCharacterThreads = pendingCharacterThreadStarts.delete(target); - const state = getState(target); - if (needsRender && state.visible) renderTarget(target); - if (startsCharacterThreads && state.visible) { - startCharacterThreads(target); - } - } - if (pendingRenderTargets.size || pendingCharacterThreadStarts.size) requestRenderFlush(); + function flushPendingRenders() { + renderFlushScheduled = false; + const renderTargets = new Set(pendingRenderTargets); + for (const target of renderTargets) { + pendingRenderTargets.delete(target); + const state = getState(target); + if (state.visible) renderTarget(target); + } + if (pendingRenderTargets.size) requestRenderFlush(); } function flushRenderIfDirty(target) { if (!pendingRenderTargets.has(target)) return; - pendingRenderTargets.delete(target); - const state = getState(target); - if (state.visible) renderTarget(target); - if (pendingCharacterThreadStarts.delete(target) && state.visible) { - startCharacterThreads(target); - } - if (pendingRenderTargets.size || pendingCharacterThreadStarts.size) requestRenderFlush(); - } - - function scheduleTextRender(target) { - pendingCharacterThreadStarts.add(target); - const state = getState(target); - if (state.paintDirty || state.layoutKey !== getLayoutKey(state) || !state.layout || - state.skinId === null || pendingRenderTargets.has(target)) { - scheduleRender(target); - } else { - requestRenderFlush(); - } + pendingRenderTargets.delete(target); + const state = getState(target); + if (state.visible) renderTarget(target); + if (pendingRenderTargets.size) requestRenderFlush(); + } + + function scheduleTextRender(target) { + scheduleRender(target); } - function schedulePaint(target, state) { - state.paintDirty = true; - state.paintFingerprint = null; - if (state.visible) scheduleRender(target); - } + function schedulePaint(target, state) { + state.paintDirty = true; + state.paintFingerprint = null; + if (state.visible) scheduleRender(target); + } function getLayoutKey(state) { return getShapeKey(state) + '\u0002' + state.charOverridesVersion; @@ -1085,11 +1108,11 @@ Enjoy!! :D if (!state.visible) return; const shapeKey = getShapeKey(state); - let layout = state.layout; - if (state.shapeKey !== shapeKey || !layout) { - const richChars = parseRichText(state.rawText, state.baseStyle); - applyCharacterStyleOverrides(richChars, state.charStyleOverrides); - for (const family of new Set(richChars.map(rc => rc.font))) { + let layout = state.layout; + if (state.shapeKey !== shapeKey || !layout) { + const richChars = parseRichText(state.rawText, state.baseStyle); + applyCharacterStyleOverrides(richChars, state.charStyleOverrides); + for (const family of new Set(richChars.map(rc => rc.font))) { ensureDocumentFont(family); } const svg = getMeasureSvg(); @@ -1109,19 +1132,19 @@ Enjoy!! :D const lineHeight = state.baseStyle.size * state.lineSpacing; const totalHeight = lines.length * lineHeight; - const decorationPad = Math.max( - 0, - Number(state.textBackground.padding) || 0, - Number(state.textBorder.size) || 0, - (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetX) || 0), - (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetY) || 0) - ); - const pad = Math.max(64, state.baseStyle.size * 2, decorationPad + state.baseStyle.size); + const decorationPad = Math.max( + 0, + Number(state.textBackground.padding) || 0, + Number(state.textBorder.size) || 0, + (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetX) || 0), + (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetY) || 0) + ); + const pad = Math.max(64, state.baseStyle.size * 2, decorationPad + state.baseStyle.size); const effectiveMaxWidth = state.maxWidth && state.maxWidth > 0 ? state.maxWidth : maxWidth; const effectiveMaxHeight = state.maxHeight && state.maxHeight > 0 ? state.maxHeight : totalHeight; - const docW = Math.ceil(effectiveMaxWidth + pad * 2); + const docW = Math.ceil(effectiveMaxWidth + pad * 2); const docH = Math.ceil(effectiveMaxHeight + pad * 2); const originX = docW / 2; @@ -1137,32 +1160,32 @@ Enjoy!! :D const paintOps = []; const letterSpacing = state.letterSpacing || 0; - for (let li = 0; li < lines.length; li++) { - const line = lines[li]; - const runs = lineRuns[li]; - const lineW = lineWidths[li]; - const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && - effectiveMaxWidth > lineW ? - line.filter(rc => rc.char === ' ').length : 0; - const justifyExtra = justifySpaceCount > 0 ? - (effectiveMaxWidth - lineW) / justifySpaceCount : 0; - let lineX; - - if (state.align === 'left') { - lineX = originX - effectiveMaxWidth / 2; - } else if (state.align === 'right') { - lineX = originX + effectiveMaxWidth / 2 - lineW; - } else if (state.align === 'justify') { - lineX = originX - effectiveMaxWidth / 2; - } else { - lineX = originX - lineW / 2; - } + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + const runs = lineRuns[li]; + const lineW = lineWidths[li]; + const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && + effectiveMaxWidth > lineW ? + line.filter(rc => rc.char === ' ').length : 0; + const justifyExtra = justifySpaceCount > 0 ? + (effectiveMaxWidth - lineW) / justifySpaceCount : 0; + let lineX; + + if (state.align === 'left') { + lineX = originX - effectiveMaxWidth / 2; + } else if (state.align === 'right') { + lineX = originX + effectiveMaxWidth / 2 - lineW; + } else if (state.align === 'justify') { + lineX = originX - effectiveMaxWidth / 2; + } else { + lineX = originX - lineW / 2; + } const textStartY = originY - totalHeight / 2; - const baseCenterY = textStartY + (li * lineHeight) + lineHeight * 0.5; - - let spacesBefore = 0; - for (const run of runs) { + const baseCenterY = textStartY + (li * lineHeight) + lineHeight * 0.5; + + let spacesBefore = 0; + for (const run of runs) { const rc0 = run.chars[0]; const underline = !!rc0.underline; @@ -1174,12 +1197,12 @@ Enjoy!! :D const rc = run.chars[i]; const pos = run.positions[i]; const index = run.startIndex + i; - const o = run.hasOverride ? - (state.charOverrides[index] || EMPTY_OVERRIDE) : - EMPTY_OVERRIDE; - - const charCenterX = runOriginX + pos.shapedX + pos.advance / 2 + - spacesBefore * justifyExtra + o.x; + const o = run.hasOverride ? + (state.charOverrides[index] || EMPTY_OVERRIDE) : + EMPTY_OVERRIDE; + + const charCenterX = runOriginX + pos.shapedX + pos.advance / 2 + + spacesBefore * justifyExtra + o.x; const charCenterY = baseCenterY - o.y; const rotation = o.rotation || 0; const opacity = o.opacity == null ? 1 : o.opacity; @@ -1197,25 +1220,25 @@ Enjoy!! :D letterSpacing, underline, strike, - width: pos.advance - }); - - if (rc.char === ' ') spacesBefore++; + width: pos.advance + }); + + if (rc.char === ' ') spacesBefore++; let box = charBoxes[index]; if (!box) { - box = { - index, - char: rc.char, - tags: rc.tags, - width: pos.advance, - height: rc.size, - style: characterStyleSnapshot(rc) - }; - charBoxes[index] = box; - } - box.style = characterStyleSnapshot(rc); - box.x = charCenterX - originX; + box = { + index, + char: rc.char, + tags: rc.tags, + width: pos.advance, + height: rc.size, + style: characterStyleSnapshot(rc) + }; + charBoxes[index] = box; + } + box.style = characterStyleSnapshot(rc); + box.x = charCenterX - originX; box.y = originY - charCenterY; } } @@ -1233,7 +1256,7 @@ Enjoy!! :D } state.paintFingerprint = fingerprint; - const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); + const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); pushCanvasToDrawable(target, canvas, docW, docH); state.paintDirty = false; } @@ -1270,7 +1293,7 @@ Enjoy!! :D const DEST_SCALE = GLYPH_OVERSAMPLE; - function compositeGlyphsToCanvas(state, paintOps, docW, docH, textWidth, textHeight, originX, originY) { + function compositeGlyphsToCanvas(state, paintOps, docW, docH, textWidth, textHeight, originX, originY) { let canvas = state.paintCanvas; if (!canvas) { canvas = document.createElement('canvas'); @@ -1283,29 +1306,29 @@ Enjoy!! :D if (canvas.width !== pixelW || canvas.height !== pixelH) { canvas.width = pixelW; canvas.height = pixelH; - } - const ctx = state.paintCtx; - ctx.imageSmoothingEnabled = !!state.smoothing; - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, pixelW, pixelH); - ctx.textBaseline = 'alphabetic'; - - const background = state.textBackground; - if (background.enabled) { - const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; - const width = textWidth * DEST_SCALE + padding * 2; - const height = textHeight * DEST_SCALE + padding * 2; - const x = originX * DEST_SCALE - width / 2; - const y = originY * DEST_SCALE - height / 2; - ctx.save(); - ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; - ctx.fillStyle = background.color; - const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); - ctx.beginPath(); - ctx.roundRect(x, y, width, height, radius); - ctx.fill(); - ctx.restore(); - } + } + const ctx = state.paintCtx; + ctx.imageSmoothingEnabled = !!state.smoothing; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, pixelW, pixelH); + ctx.textBaseline = 'alphabetic'; + + const background = state.textBackground; + if (background.enabled) { + const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; + const width = textWidth * DEST_SCALE + padding * 2; + const height = textHeight * DEST_SCALE + padding * 2; + const x = originX * DEST_SCALE - width / 2; + const y = originY * DEST_SCALE - height / 2; + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; + ctx.fillStyle = background.color; + const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.fill(); + ctx.restore(); + } for (let i = 0; i < paintOps.length; i++) { const op = paintOps[i]; @@ -1323,71 +1346,71 @@ Enjoy!! :D const drawXEm = op.x - advanceCenterXEm; const drawYEm = (op.y + baselineYFromLineCenter) - glyph.baselineOriginYEm; - const hasOpacity = op.opacity != null && op.opacity !== 1; - const hasRotation = !!op.rotation; - - const drawGlyph = (drawX, drawY) => { - ctx.drawImage(glyph.canvas, drawX, drawY); - }; - - if (state.textShadow.enabled) { - ctx.save(); - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; - ctx.shadowColor = state.textShadow.color; - ctx.shadowBlur = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; - ctx.shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE; - ctx.shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE; - if (!hasRotation) { - drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - } else { - const cx = op.x * DEST_SCALE; - const cy = op.y * DEST_SCALE; - ctx.translate(cx, cy); - ctx.rotate(-op.rotation * Math.PI / 180); - ctx.translate(-cx, -cy); - drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - } - ctx.restore(); - } - - if (op.underline || op.strike) { - ctx.save(); - if (hasOpacity) ctx.globalAlpha = op.opacity; - ctx.strokeStyle = op.color; - ctx.lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; - if (op.strike) { - const sy = op.y * DEST_SCALE; - ctx.beginPath(); - ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); - ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); - ctx.stroke(); - } - ctx.restore(); - } - - if (state.textBorder.enabled && Number(state.textBorder.size) > 0) { - ctx.save(); - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textBorder.opacity) || 0)) / 100; - ctx.shadowColor = state.textBorder.color; - ctx.shadowBlur = 0; - const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; - const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; - for (const [offsetX, offsetY] of borderOffsets) { - if (!hasRotation) { - drawGlyph(drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); - } else { - const cx = op.x * DEST_SCALE; - const cy = op.y * DEST_SCALE; - ctx.save(); - ctx.translate(cx, cy); - ctx.rotate(-op.rotation * Math.PI / 180); - ctx.translate(-cx, -cy); - drawGlyph(drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); - ctx.restore(); - } - } - ctx.restore(); - } + const hasOpacity = op.opacity != null && op.opacity !== 1; + const hasRotation = !!op.rotation; + + const drawGlyph = (drawX, drawY) => { + ctx.drawImage(glyph.canvas, drawX, drawY); + }; + + if (state.textShadow.enabled) { + ctx.save(); + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; + ctx.shadowColor = state.textShadow.color; + ctx.shadowBlur = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; + ctx.shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE; + ctx.shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE; + if (!hasRotation) { + drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + } else { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.translate(cx, cy); + ctx.rotate(-op.rotation * Math.PI / 180); + ctx.translate(-cx, -cy); + drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + } + ctx.restore(); + } + + if (op.underline || op.strike) { + ctx.save(); + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + ctx.lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + if (op.strike) { + const sy = op.y * DEST_SCALE; + ctx.beginPath(); + ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); + ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); + ctx.stroke(); + } + ctx.restore(); + } + + if (state.textBorder.enabled && Number(state.textBorder.size) > 0) { + const shape = getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); + const borderGlyph = tintGlyph(shape, state.textBorder.color); + ctx.save(); + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textBorder.opacity) || 0)) / 100; + const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; + const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; + for (const [offsetX, offsetY] of borderOffsets) { + if (!hasRotation) { + ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); + } else { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-op.rotation * Math.PI / 180); + ctx.translate(-cx, -cy); + ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); + ctx.restore(); + } + } + ctx.restore(); + } if (!hasRotation) { const px = Math.round(drawXEm * DEST_SCALE); @@ -1408,40 +1431,40 @@ Enjoy!! :D ctx.translate(cx, cy); ctx.rotate(-op.rotation * Math.PI / 180); ctx.translate(-cx, -cy); - ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - ctx.restore(); - } - - if (op.underline) { - ctx.save(); - if (hasOpacity) ctx.globalAlpha = op.opacity; - ctx.strokeStyle = op.color; - const lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; - ctx.lineWidth = lineWidth; - const uy = (op.y + baselineYFromLineCenter + fontSize * 0.06) * DEST_SCALE; - const startX = (op.x - op.width / 2) * DEST_SCALE; - const endX = (op.x + op.width / 2) * DEST_SCALE; - if (hasRotation) { - ctx.beginPath(); - ctx.moveTo(startX, uy); - ctx.lineTo(endX, uy); - ctx.stroke(); - } else { - strokeUnderline(ctx, glyph, Math.round(drawXEm * DEST_SCALE), Math.round(drawYEm * DEST_SCALE), startX, endX, uy, lineWidth); - } - ctx.restore(); - } - - } + ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + ctx.restore(); + } + + if (op.underline) { + ctx.save(); + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + const lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + ctx.lineWidth = lineWidth; + const uy = (op.y + baselineYFromLineCenter + fontSize * 0.06) * DEST_SCALE; + const startX = (op.x - op.width / 2) * DEST_SCALE; + const endX = (op.x + op.width / 2) * DEST_SCALE; + if (hasRotation) { + ctx.beginPath(); + ctx.moveTo(startX, uy); + ctx.lineTo(endX, uy); + ctx.stroke(); + } else { + strokeUnderline(ctx, glyph, Math.round(drawXEm * DEST_SCALE), Math.round(drawYEm * DEST_SCALE), startX, endX, uy, lineWidth); + } + ctx.restore(); + } + + } return canvas; } - function installLinearFiltering(skin, state) { - skin.useNearest = function(scale) { - if (!state.smoothing) return true; - const s = Math.max(Math.abs(scale[0]), Math.abs(scale[1])); - return s >= (DEST_SCALE * 100 - 5); + function installLinearFiltering(skin, state) { + skin.useNearest = function(scale) { + if (!state.smoothing) return true; + const s = Math.max(Math.abs(scale[0]), Math.abs(scale[1])); + return s >= (DEST_SCALE * 100 - 5); }; } @@ -1452,18 +1475,18 @@ Enjoy!! :D const rotationCenter = [width / 2, height / 2]; if (state.skinId === null) { - const BitmapSkin = renderer.exports.BitmapSkin; - const skinId = renderer._nextSkinId++; - const skin = new BitmapSkin(skinId, renderer); - installLinearFiltering(skin, state); + const BitmapSkin = renderer.exports.BitmapSkin; + const skinId = renderer._nextSkinId++; + const skin = new BitmapSkin(skinId, renderer); + installLinearFiltering(skin, state); skin.setBitmap(canvas, DEST_SCALE, rotationCenter); renderer._allSkins[skinId] = skin; state.skinId = skinId; } else { - renderer.updateBitmapSkin(state.skinId, canvas, DEST_SCALE, rotationCenter); - const current = renderer._allSkins[state.skinId]; - if (current && !Object.prototype.hasOwnProperty.call(current, 'useNearest')) { - installLinearFiltering(current, state); + renderer.updateBitmapSkin(state.skinId, canvas, DEST_SCALE, rotationCenter); + const current = renderer._allSkins[state.skinId]; + if (current && !Object.prototype.hasOwnProperty.call(current, 'useNearest')) { + installLinearFiltering(current, state); } } renderer.updateDrawableSkinId(target.drawableID, state.skinId); @@ -1473,379 +1496,110 @@ Enjoy!! :D function clearTarget(target) { const state = getState(target); state.visible = false; - state.revealToken++; - state.paintDirty = true; - state.paintFingerprint = null; - pendingCharacterThreadStarts.delete(target); - const costume = target.getCostumes()[target.currentCostume]; + state.revealToken++; + state.paintDirty = true; + state.paintFingerprint = null; + const costume = target.getCostumes()[target.currentCostume]; if (costume && runtime.renderer) { runtime.renderer.updateDrawableSkinId(target.drawableID, costume.skinId); runtime.requestRedraw(); } - stopCharacterHatThreads(target); - } - - const charScripts = {}; - - function stopCharacterHatThreads(target) { - target._irisCharSequenceActive = false; - const threads = target._irisCharSequenceThreads; - if (threads && threads.length) { - const ownedThreads = new Set(threads); - for (let i = runtime.threads.length - 1; i >= 0; i--) { - const thread = runtime.threads[i]; - if (ownedThreads.has(thread)) { - thread.isKilled = true; - runtime.threads.splice(i, 1); - } - } - } - target._irisCharSequenceThreads = []; - target._irisCharThreadCache = null; - } - - function createBlocksSnapshot(sourceBlocks, rootBlockId, rt) { - const BlocksClass = sourceBlocks.constructor; - const snapshot = new BlocksClass(rt, true); - snapshot.forceNoGlow = true; - - const sourceMap = sourceBlocks._blocks; - const visited = Object.create(null); - - function copyBlock(id) { - if (!id || visited[id] || !sourceMap[id]) return; - visited[id] = true; - const original = sourceMap[id]; - const clone = JSON.parse(JSON.stringify(original)); - snapshot._blocks[id] = clone; - - if (clone.inputs) { - for (const inputKey in clone.inputs) { - const input = clone.inputs[inputKey]; - if (input && input.block) { - copyBlock(input.block); - } - if (input && input.shadow && input.shadow !== input.block) { - copyBlock(input.shadow); - } - } - } - - if (clone.next) { - copyBlock(clone.next); - } - - if (clone.opcode === 'procedures_call' && clone.mutation && clone.mutation.proccode) { - const defId = sourceBlocks.getProcedureDefinition(clone.mutation.proccode); - if (defId) { - copyBlock(defId); - } - } - } - - copyBlock(rootBlockId); - - if (snapshot._blocks[rootBlockId]) { - snapshot._blocks[rootBlockId].parent = null; - snapshot._blocks[rootBlockId].topLevel = true; - } - snapshot._scripts = [rootBlockId]; - - return snapshot; - } - - const CHAR_REPORTER_OPCODES = ['irisText_currentCharValue', 'irisText_currentCharIndex']; - - function stackUsesCharReporter(blocks, blockId) { - const seen = Object.create(null); - - function walk(id) { - if (!id || seen[id]) return false; - seen[id] = true; - const block = blocks.getBlock(id); - if (!block) return false; - if (CHAR_REPORTER_OPCODES.indexOf(block.opcode) !== -1) return true; - - if (block.inputs) { - for (const key in block.inputs) { - const input = block.inputs[key]; - if (input && input.block && walk(input.block)) return true; - if (input && input.shadow && input.shadow !== input.block && walk(input.shadow)) return true; - } - } - if (block.opcode === 'procedures_call' && block.mutation && block.mutation.proccode) { - const defId = blocks.getProcedureDefinition(block.mutation.proccode); - if (defId && walk(defId)) return true; - } - if (walk(block.next)) return true; - return false; - } - - return walk(blockId); - } - - let ThreadClass = null; - - function createCharacterThread(entry, box) { - if (!ThreadClass) { - ThreadClass = runtime.threads.length > 0 ? runtime.threads[0].constructor : null; - } - if (!ThreadClass) return null; - const thread = new ThreadClass(entry.stack || 'irisText_char'); - thread.target = entry.target; - thread.stackClick = false; - thread.updateMonitor = false; - thread.blockContainer = entry.blocks || entry.target.blocks; - thread._irisChar = box; - thread.pushStack(entry.stack || 'irisText_char'); - if (entry.func) { - thread.procedures = Object.assign({}, entry.proc); - thread.generator = entry.func(thread, entry.target, runtime, runtime.getTargetForStage ? runtime.getTargetForStage() : null); - thread.isCompiled = true; - } else if (entry.compiledResult) { - const result = entry.compiledResult; - thread.procedures = {}; - if (result.procedures) { - for (const procedureCode of Object.keys(result.procedures)) { - thread.procedures[procedureCode] = result.procedures[procedureCode](thread); - } - } - if (typeof result.startingFunction === 'function') { - thread.generator = result.startingFunction(thread)(); - } - thread.executableHat = result.executableHat; - thread.isCompiled = true; - } else if (typeof thread.tryCompile === 'function' && runtime.compilerOptions && runtime.compilerOptions.enabled) { - thread.tryCompile(); - } - runtime.threads.push(thread); - if (runtime.threadMap && typeof thread.getId === 'function') { - runtime.threadMap.set(thread.getId(), thread); - } - return thread; - } - - function restartCharacterThread(cached, entry, box) { - if (!ThreadClass) ThreadClass = cached.constructor; - const newThread = new ThreadClass(entry.stack || 'irisText_char'); - newThread.target = entry.target; - newThread.stackClick = false; - newThread.updateMonitor = false; - newThread.blockContainer = entry.blocks || entry.target.blocks; - newThread._irisChar = box; - newThread.pushStack(entry.stack || 'irisText_char'); - if (entry.func) { - newThread.procedures = Object.assign({}, entry.proc); - newThread.generator = entry.func(newThread, entry.target, runtime, runtime.getTargetForStage ? runtime.getTargetForStage() : null); - newThread.isCompiled = true; - } else if (entry.compiledResult) { - const result = entry.compiledResult; - newThread.procedures = {}; - if (result.procedures) { - for (const procedureCode of Object.keys(result.procedures)) { - newThread.procedures[procedureCode] = result.procedures[procedureCode](newThread); - } - } - if (typeof result.startingFunction === 'function') { - newThread.generator = result.startingFunction(newThread)(); - } - newThread.executableHat = result.executableHat; - newThread.isCompiled = true; - } else if (typeof newThread.tryCompile === 'function' && runtime.compilerOptions && runtime.compilerOptions.enabled) { - newThread.tryCompile(); - } - if (runtime.threadMap && typeof newThread.getId === 'function') { - runtime.threadMap.set(newThread.getId(), newThread); - } - const idx = runtime.threads.indexOf(cached); - if (idx !== -1) { - runtime.threads[idx] = newThread; - } else { - runtime.threads.push(newThread); - } - return newThread; - } - - function startCharacterThread(entry, box, threads, cache, nextCache, liveThreads) { - if (!entry.func && (!entry.blocks || entry.blocks.getBlock(entry.stack) === undefined)) return; - const cacheKey = entry.tag + '\u0001' + box.index; - const cached = cache && cache.get(cacheKey); - let thread; - if (cached && (entry.func || cached.topBlock === entry.stack) && liveThreads.has(cached)) { - thread = restartCharacterThread(cached, entry, box); - } else { - thread = createCharacterThread(entry, box); - } - if (!thread) return; - if (thread.status !== thread.constructor.STATUS_DONE) { - runtime.sequencer.stepThread(thread); - } - nextCache.set(cacheKey, thread); - threads.push(thread); - } - - function startCharacterThreadsForEntry(state, entry, threads, cache, nextCache, liveThreads) { - const indices = state.charsByTag.get(entry.tag); - if (!indices || !indices.length) return; - - if (entry.perChar) { - for (const idx of indices) { - const box = state.charBoxes[idx]; - if (box) startCharacterThread(entry, box, threads, cache, nextCache, liveThreads); - } - } else { - const box = state.charBoxes[indices[0]]; - if (box) startCharacterThread(entry, box, threads, cache, nextCache, liveThreads); - } - } - - function startCharacterThreads(target) { - const state = getState(target); - const name = target.id; - - const previousThreads = target._irisCharSequenceThreads; - const cache = target._irisCharThreadCache; - const nextCache = new Map(); - const liveThreads = new Set(runtime.threads); - - runtime.startHats('irisText_onRenderComplete', null, target); - - const scriptMap = charScripts[name]; - const threads = []; - target._irisCharSequenceThreads = threads; - - if (scriptMap) { - for (const tag in scriptMap) { - const entry = scriptMap[tag]; - if (entry) { - startCharacterThreadsForEntry(state, entry, threads, cache, nextCache, liveThreads); - } - } - } - - if (previousThreads && previousThreads.length) { - const reused = new Set(threads); - for (let i = runtime.threads.length - 1; i >= 0; i--) { - const thread = runtime.threads[i]; - if (!reused.has(thread) && previousThreads.indexOf(thread) !== -1) { - thread.isKilled = true; - runtime.threads.splice(i, 1); - } - } - } - - target._irisCharThreadCache = nextCache; - target._irisCharSequenceActive = threads.length > 0; - } - - function charThreadsStillActive(target) { - const threads = target._irisCharSequenceThreads; - if (!threads || !threads.length) { - target._irisCharSequenceActive = false; - return false; - } - const sequenceThreads = new Set(threads); - const active = []; - for (const thread of runtime.threads) { - if (sequenceThreads.has(thread)) active.push(thread); - } - target._irisCharSequenceThreads = active; - if (!active.length) { - target._irisCharSequenceActive = false; - return false; - } - return true; - } - - function startTypingCharacterHat(target, char, index) { - const current = { - char, - index - }; - target._irisTypingChar = current; - const threads = runtime.startHats('irisText_onCharacterType', null, target); - if (Array.isArray(threads)) { - for (const thread of threads) { - thread._irisChar = current; - } - } - target._irisTypingChar = null; - } - - class IrisText { + } + + function startTypingCharacterHat(target, char, index) { + const current = { + char, + index + }; + target._irisTypingChar = current; + const threads = runtime.startHats('irisText_onCharacterType', null, target); + if (Array.isArray(threads)) { + for (const thread of threads) { + thread._irisChar = current; + } + } + target._irisTypingChar = null; + } + + function taggedCharacterIndices(target, tag) { + const state = getState(target); + const chars = parseRichText(state.rawText, state.baseStyle); + const indices = []; + for (let index = 0; index < chars.length; index++) { + if (chars[index].tags.includes(tag)) indices.push(index); + } + return indices; + } + + class IrisText { constructor() { this._onTargetRemoved = this._onTargetRemoved.bind(this); runtime.on('targetWasRemoved', this._onTargetRemoved); - runtime.on('PROJECT_STOP_ALL', () => { - runtime.targets.forEach(t => clearTarget(t)); - }); - runtime.ext_irisText = this; - if (typeof runtime.registerCompiledExtensionBlocks === 'function') { - runtime.registerCompiledExtensionBlocks('irisText', this.getCompileInfo()); - } - } - - _scheduleCompiledTag(tagInput, func, thread, target) { - const tag = Scratch.Cast.toString(tagInput).toLowerCase(); - const name = target.id; - if (charScripts[name] === undefined) { - charScripts[name] = Object.create(null); - } - charScripts[name][tag] = { - func: func, - proc: thread && thread.procedures ? Object.assign({}, thread.procedures) : {}, - target: target, - tag: tag, - perChar: true - }; - } - - getCompileInfo() { - return { - ir: { - scheduleTagBlocks: (generator, block) => ({ - kind: 'stack', - tag: generator.descendInputOfBlock(block, 'TAG'), - substack: generator.descendSubstack(block, 'SUBSTACK') - }), - currentCharValue: (generator, block) => ({ - kind: 'input' - }), - currentCharIndex: (generator, block) => ({ - kind: 'input' - }) - }, - js: { - scheduleTagBlocks: (node, compiler, imports) => { - const temp = compiler.source; - compiler.source = '(function*(thread, target, runtime, stage) {\n'; - if (node.substack) { - compiler.descendStack(node.substack, new imports.Frame(false)); - } - compiler.source += '})'; - const funcExpr = compiler.source; - compiler.source = temp; - compiler.source += `runtime.ext_irisText._scheduleCompiledTag(${compiler.descendInput(node.tag).asString()}, ${funcExpr}, thread, target);\n`; - }, - currentCharValue: (node, compiler, imports) => { - return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.char : (target && target._irisTypingChar ? target._irisTypingChar.char : ""))', imports.TYPE_STRING); - }, - currentCharIndex: (node, compiler, imports) => { - return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.index + 1 : (target && target._irisTypingChar ? target._irisTypingChar.index + 1 : 0))', imports.TYPE_NUMBER); - } - } - }; - } - - _onTargetRemoved(target) { + runtime.on('PROJECT_STOP_ALL', () => { + runtime.targets.forEach(t => clearTarget(t)); + }); + runtime.ext_irisText = this; + if (typeof runtime.registerCompiledExtensionBlocks === 'function') { + runtime.registerCompiledExtensionBlocks('irisText', this.getCompileInfo()); + } + } + + *_repeatCompiledTag(tagInput, func, thread, target, stage) { + const tag = Scratch.Cast.toString(tagInput).toLowerCase(); + const indices = taggedCharacterIndices(target, tag); + const rootFrame = thread.stackFrames[0]; + rootFrame.irisTagCharacterNumber = 0; + + for (const index of indices) { + rootFrame.irisTagCharacterNumber = index + 1; + yield* func(thread, target, runtime, stage); + } + } + + getCompileInfo() { + return { + ir: { + repeatForTag: (generator, block) => ({ + kind: 'stack', + tag: generator.descendInputOfBlock(block, 'TAG'), + substack: generator.descendSubstack(block, 'SUBSTACK') + }), + currentCharValue: () => ({ + kind: 'input' + }), + currentCharIndex: () => ({ + kind: 'input' + }) + }, + js: { + repeatForTag: (node, compiler, imports) => { + const temp = compiler.source; + compiler.source = '(function*(thread, target, runtime, stage) {\n'; + if (node.substack) { + compiler.descendStack(node.substack, new imports.Frame(false)); + } + compiler.source += '})'; + const funcExpr = compiler.source; + compiler.source = temp; + compiler.source += `yield* runtime.ext_irisText._repeatCompiledTag(${compiler.descendInput(node.tag).asString()}, ${funcExpr}, thread, target, stage);\n`; + }, + currentCharValue: (node, compiler, imports) => { + return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.char : (target && target._irisTypingChar ? target._irisTypingChar.char : ""))', imports.TYPE_STRING); + }, + currentCharIndex: (node, compiler, imports) => { + return new imports.TypedInput('(thread && thread.stackFrames[0] && thread.stackFrames[0].irisTagCharacterNumber !== undefined ? thread.stackFrames[0].irisTagCharacterNumber : (thread && thread._irisChar ? thread._irisChar.index + 1 : (target && target._irisTypingChar ? target._irisTypingChar.index + 1 : 0)))', imports.TYPE_NUMBER); + } + } + }; + } + + _onTargetRemoved(target) { const state = target.getCustomState(STATE_KEY); if (state && state.skinId !== null && runtime.renderer) { runtime.renderer.destroySkin(state.skinId); state.skinId = null; } - stopCharacterHatThreads(target); - delete charScripts[target.id]; - } + } getInfo() { return { @@ -1928,304 +1682,309 @@ Enjoy!! :D } } }, - { - opcode: 'setAlign', - blockType: Scratch.BlockType.COMMAND, + { + opcode: 'setAlign', + blockType: Scratch.BlockType.COMMAND, text: 'align text [ALIGN]', arguments: { ALIGN: { type: Scratch.ArgumentType.STRING, menu: 'ALIGN', defaultValue: 'center' - } - } + } + } + }, + { + opcode: 'setSmoothing', + blockType: Scratch.BlockType.COMMAND, + text: 'smoothing [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Typing' + }, + { + opcode: 'typeText', + blockType: Scratch.BlockType.COMMAND, + text: 'type [TEXT]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: DEFAULT_TEXT + } + } + }, + { + opcode: 'typeNewLine', + blockType: Scratch.BlockType.COMMAND, + text: 'type new line [TEXT]', + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: DEFAULT_TEXT + } + } + }, + { + opcode: 'skipTyping', + blockType: Scratch.BlockType.COMMAND, + text: 'skip typing' + }, + { + opcode: 'stopTyping', + blockType: Scratch.BlockType.COMMAND, + text: 'stop typing' + }, + { + opcode: 'setTypingSpeed', + blockType: Scratch.BlockType.COMMAND, + text: 'set typing speed for [GROUP] characters to [SECONDS] seconds', + arguments: { + GROUP: { + type: Scratch.ArgumentType.STRING, + menu: 'TYPING_GROUP', + defaultValue: 'default' + }, + SECONDS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0.05 + } + } + }, + { + opcode: 'setCustomTypingSpeed', + blockType: Scratch.BlockType.COMMAND, + text: 'set typing speed for custom characters [CHARS] to [SECONDS] seconds', + arguments: { + CHARS: { + type: Scratch.ArgumentType.STRING, + defaultValue: '…' + }, + SECONDS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0.2 + } + } + }, + { + opcode: 'onCharacterType', + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: false, + text: 'on character type' + }, + { + opcode: 'currentCharValue', + blockType: Scratch.BlockType.REPORTER, + text: 'character' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Shadow' + }, + { + opcode: 'setTextShadowEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text shadow [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextShadowColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextShadowOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'setTextShadowBlur', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow blur to [BLUR]', + arguments: { + BLUR: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + opcode: 'setTextShadowOffset', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow offset x [X] y [Y]', + arguments: { + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 4 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 4 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Background' + }, + { + opcode: 'setTextBackgroundEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text background [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextBackgroundColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextBackgroundOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'setTextBackgroundRadius', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background radius to [RADIUS]', + arguments: { + RADIUS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + opcode: 'setTextBackgroundPadding', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background padding to [PADDING]', + arguments: { + PADDING: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Border' + }, + { + opcode: 'setTextBorderEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text border [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextBorderColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextBorderOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'setTextBorderSize', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border size to [SIZE]', + arguments: { + SIZE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 2 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Size Constraints' }, { - opcode: 'setSmoothing', + opcode: 'setMaxWidth', blockType: Scratch.BlockType.COMMAND, - text: 'smoothing [ENABLED]', + text: 'set max width to [WIDTH]', arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' + WIDTH: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 } } }, { - blockType: Scratch.BlockType.LABEL, - text: 'Typing' - }, - { - opcode: 'typeText', + opcode: 'setMaxHeight', blockType: Scratch.BlockType.COMMAND, - text: 'type [TEXT]', + text: 'set max height to [HEIGHT]', arguments: { - TEXT: { - type: Scratch.ArgumentType.STRING, - defaultValue: DEFAULT_TEXT + HEIGHT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 } } }, { - opcode: 'typeNewLine', + opcode: 'setMaxDimensions', blockType: Scratch.BlockType.COMMAND, - text: 'type new line [TEXT]', - arguments: { - TEXT: { - type: Scratch.ArgumentType.STRING, - defaultValue: DEFAULT_TEXT - } - } - }, - { - opcode: 'skipTyping', - blockType: Scratch.BlockType.COMMAND, - text: 'skip typing' - }, - { - opcode: 'stopTyping', - blockType: Scratch.BlockType.COMMAND, - text: 'stop typing' - }, - { - opcode: 'setTypingSpeed', - blockType: Scratch.BlockType.COMMAND, - text: 'set typing speed for [GROUP] characters to [SECONDS] seconds', - arguments: { - GROUP: { - type: Scratch.ArgumentType.STRING, - menu: 'TYPING_GROUP', - defaultValue: 'default' - }, - SECONDS: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0.05 - } - } - }, - { - opcode: 'setCustomTypingSpeed', - blockType: Scratch.BlockType.COMMAND, - text: 'set typing speed for custom characters [CHARS] to [SECONDS] seconds', - arguments: { - CHARS: { - type: Scratch.ArgumentType.STRING, - defaultValue: '…' - }, - SECONDS: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0.2 - } - } - }, - { - opcode: 'onCharacterType', - blockType: Scratch.BlockType.EVENT, - isEdgeActivated: false, - shouldRestartExistingThreads: false, - text: 'on character type' - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Shadow' - }, - { - opcode: 'setTextShadowEnabled', - blockType: Scratch.BlockType.COMMAND, - text: 'text shadow [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setTextShadowColor', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow color to [COLOR]', - arguments: { - COLOR: { - type: Scratch.ArgumentType.COLOR - } - } - }, - { - opcode: 'setTextShadowOpacity', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow opacity to [PCT] %', - arguments: { - PCT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 50 - } - } - }, - { - opcode: 'setTextShadowBlur', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow blur to [BLUR]', - arguments: { - BLUR: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 8 - } - } - }, - { - opcode: 'setTextShadowOffset', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow offset x [X] y [Y]', - arguments: { - X: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 4 - }, - Y: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 4 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Background' - }, - { - opcode: 'setTextBackgroundEnabled', - blockType: Scratch.BlockType.COMMAND, - text: 'text background [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setTextBackgroundColor', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background color to [COLOR]', - arguments: { - COLOR: { - type: Scratch.ArgumentType.COLOR - } - } - }, - { - opcode: 'setTextBackgroundOpacity', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background opacity to [PCT] %', - arguments: { - PCT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 50 - } - } - }, - { - opcode: 'setTextBackgroundRadius', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background radius to [RADIUS]', - arguments: { - RADIUS: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 8 - } - } - }, - { - opcode: 'setTextBackgroundPadding', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background padding to [PADDING]', - arguments: { - PADDING: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 8 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Border' - }, - { - opcode: 'setTextBorderEnabled', - blockType: Scratch.BlockType.COMMAND, - text: 'text border [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setTextBorderColor', - blockType: Scratch.BlockType.COMMAND, - text: 'set text border color to [COLOR]', - arguments: { - COLOR: { - type: Scratch.ArgumentType.COLOR - } - } - }, - { - opcode: 'setTextBorderOpacity', - blockType: Scratch.BlockType.COMMAND, - text: 'set text border opacity to [PCT] %', - arguments: { - PCT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 100 - } - } - }, - { - opcode: 'setTextBorderSize', - blockType: Scratch.BlockType.COMMAND, - text: 'set text border size to [SIZE]', - arguments: { - SIZE: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 2 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Size Constraints' - }, - { - opcode: 'setMaxWidth', - blockType: Scratch.BlockType.COMMAND, - text: 'set max width to [WIDTH]', - arguments: { - WIDTH: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 - } - } - }, - { - opcode: 'setMaxHeight', - blockType: Scratch.BlockType.COMMAND, - text: 'set max height to [HEIGHT]', - arguments: { - HEIGHT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 - } - } - }, - { - opcode: 'setMaxDimensions', - blockType: Scratch.BlockType.COMMAND, - text: 'set max width to [WIDTH] and max height to [HEIGHT]', + text: 'set max width to [WIDTH] and max height to [HEIGHT]', arguments: { WIDTH: { type: Scratch.ArgumentType.NUMBER, @@ -2242,98 +2001,87 @@ Enjoy!! :D blockType: Scratch.BlockType.COMMAND, text: 'remove max width and height bounds' }, - { - opcode: 'setLetterSpacing', - blockType: Scratch.BlockType.COMMAND, + { + opcode: 'setLetterSpacing', + blockType: Scratch.BlockType.COMMAND, text: 'set letter spacing to [SPACE]', arguments: { SPACE: { type: Scratch.ArgumentType.NUMBER, defaultValue: 0 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Settings' - }, - { - opcode: 'exportSettings', - blockType: Scratch.BlockType.REPORTER, - text: 'export settings as JSON' - }, - { - opcode: 'applyExportSettings', - blockType: Scratch.BlockType.COMMAND, - text: 'apply JSON export settings [SETTINGS] to [SPRITE]', - arguments: { - SETTINGS: { - type: Scratch.ArgumentType.STRING, - defaultValue: '{"version":1}' - }, - SPRITE: { - type: Scratch.ArgumentType.STRING, - menu: 'SPRITE', - defaultValue: '_myself_' - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Per-Character Scripting' - }, - { - opcode: 'onRenderComplete', - blockType: Scratch.BlockType.EVENT, - isEdgeActivated: false, - shouldRestartExistingThreads: true, - text: 'when render finishes' - }, - { - opcode: 'scheduleTagBlocks', - blockType: Scratch.BlockType.COMMAND, - text: 'schedule blocks for tag [TAG]', - branchCount: 1, - arguments: { - TAG: { - type: Scratch.ArgumentType.STRING, - defaultValue: 'shake' - } - } - }, - { - opcode: 'currentCharValue', - blockType: Scratch.BlockType.REPORTER, - text: 'character' - }, - { - opcode: 'currentCharIndex', - blockType: Scratch.BlockType.REPORTER, - text: 'character #' - }, - { - opcode: 'waitForCharsToFinish', - blockType: Scratch.BlockType.COMMAND, - text: 'wait until characters finish rendering' - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Character Transforms' - }, - { - opcode: 'setResetCharTransformsOnText', - blockType: Scratch.BlockType.COMMAND, - text: 'reset character transforms on show/type text [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setCharPos', + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Settings' + }, + { + opcode: 'exportSettings', + blockType: Scratch.BlockType.REPORTER, + text: 'export settings as JSON' + }, + { + opcode: 'applyExportSettings', + blockType: Scratch.BlockType.COMMAND, + text: 'apply JSON export settings [SETTINGS] to [SPRITE]', + arguments: { + SETTINGS: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"version":1}' + }, + SPRITE: { + type: Scratch.ArgumentType.STRING, + menu: 'SPRITE', + defaultValue: '_myself_' + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Tag Loops' + }, + { + opcode: 'repeatForTag', + blockType: Scratch.BlockType.LOOP, + text: 'repeat for blocks in tag [TAG] [CHAR]', + branchCount: 1, + arguments: { + TAG: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'shake' + }, + CHAR: { + type: Scratch.ArgumentType.STRING, + fillIn: 'currentCharIndex' + } + } + }, + { + opcode: 'currentCharIndex', + blockType: Scratch.BlockType.REPORTER, + text: 'character #', + canDragDuplicate: true, + hideFromPalette: true + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Character Transforms' + }, + { + opcode: 'setResetCharTransformsOnText', + blockType: Scratch.BlockType.COMMAND, + text: 'reset character transforms on show/type text [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setCharPos', blockType: Scratch.BlockType.COMMAND, text: 'set character [INDEX] position to x: [X] y: [Y]', arguments: { @@ -2426,55 +2174,55 @@ Enjoy!! :D } } }, - { - opcode: 'resetAllCharTransforms', - blockType: Scratch.BlockType.COMMAND, - text: 'reset all character transforms' - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Character Style' - }, - { - opcode: 'getCharacterStyle', - blockType: Scratch.BlockType.REPORTER, - text: 'style of character [INDEX] as JSON', - arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - } - } - }, - { - opcode: 'setCharacterStyle', - blockType: Scratch.BlockType.COMMAND, - text: 'set style of character [INDEX] to JSON [STYLE]', - arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - STYLE: { - type: Scratch.ArgumentType.STRING, - defaultValue: '{"color":"#9966ff","bold":false,"italic":false,"underline":false,"strike":false,"size":32,"font":"Handwriting","tags":[]}' - } - } - }, - { - opcode: 'resetCharacterStyle', - blockType: Scratch.BlockType.COMMAND, - text: 'reset style of character [INDEX]', - arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Info' + { + opcode: 'resetAllCharTransforms', + blockType: Scratch.BlockType.COMMAND, + text: 'reset all character transforms' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Character Style' + }, + { + opcode: 'getCharacterStyle', + blockType: Scratch.BlockType.REPORTER, + text: 'style of character [INDEX] as JSON', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'setCharacterStyle', + blockType: Scratch.BlockType.COMMAND, + text: 'set style of character [INDEX] to JSON [STYLE]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + STYLE: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"color":"#9966ff","bold":false,"italic":false,"underline":false,"strike":false,"size":32,"font":"Handwriting","tags":[]}' + } + } + }, + { + opcode: 'resetCharacterStyle', + blockType: Scratch.BlockType.COMMAND, + text: 'reset style of character [INDEX]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Info' }, { opcode: 'getCharX', @@ -2616,41 +2364,40 @@ Enjoy!! :D } } ], - menus: { - ONOFF: { - items: ['on', 'off'] - }, - ALIGN: { - items: ['left', 'center', 'right', 'justify'] - }, - STYLE: { - items: ['bold', 'italic', 'underline', 'strikethrough'] - }, - TYPING_GROUP: { - items: ['default', 'symbols', 'punctuation', 'numbers'] - }, - FONT: { - items: 'getFontMenuItems', - isTypeable: true - }, - SPRITE: { - items: 'getSpriteMenuItems' - } - } - }; - } - - setText(args, util) { - if (util && util.thread && !ThreadClass) ThreadClass = util.thread.constructor; - const state = getState(util.target); + menus: { + ONOFF: { + items: ['on', 'off'] + }, + ALIGN: { + items: ['left', 'center', 'right', 'justify'] + }, + STYLE: { + items: ['bold', 'italic', 'underline', 'strikethrough'] + }, + TYPING_GROUP: { + items: ['default', 'symbols', 'punctuation', 'numbers'] + }, + FONT: { + items: 'getFontMenuItems', + isTypeable: true + }, + SPRITE: { + items: 'getSpriteMenuItems' + } + } + }; + } + + setText(args, util) { + const state = getState(util.target); const frame = util.stackFrame; - if (frame.irisStages === undefined) { - state.rawText = Scratch.Cast.toString(args.TEXT); - state.finalText = stripMarkup(state.rawText); - state.visible = true; - if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); - frame.irisStages = splitWaitStages(state.rawText); + if (frame.irisStages === undefined) { + state.rawText = Scratch.Cast.toString(args.TEXT); + state.finalText = stripMarkup(state.rawText); + state.visible = true; + if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); + frame.irisStages = splitWaitStages(state.rawText); frame.irisStageIndex = 0; frame.irisToken = ++state.revealToken; frame.irisWaitUntil = null; @@ -2674,8 +2421,8 @@ Enjoy!! :D frame.irisStageIndex++; if (frame.irisStageIndex >= frame.irisStages.length) return; - frame.irisWaitUntil = Date.now() + stage.waitAfter * 1000; - util.yieldTick(); + frame.irisWaitUntil = Date.now() + stage.waitAfter * 1000; + util.yieldTick(); } addLine(args, util) { @@ -2690,97 +2437,96 @@ Enjoy!! :D state.finalText = stripMarkup(state.rawText); state.visible = true; schedulePaint(util.target, state); - } - - _typeText(args, util, appendLine) { - if (util && util.thread && !ThreadClass) ThreadClass = util.thread.constructor; - const state = getState(util.target); - const frame = util.stackFrame; - - if (frame.irisTypingSteps === undefined) { - const text = Scratch.Cast.toString(args.TEXT); - const existingText = appendLine && state.visible ? state.rawText : ''; - const linePrefix = appendLine && existingText ? '\n' : ''; - state.rawText = existingText + linePrefix; - state.finalText = stripMarkup(state.rawText + text); - state.visible = true; - if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); - state.revealToken++; - state.typingControl = null; - frame.irisTypingSteps = splitTypingSteps(text); - frame.irisTypingStepIndex = 0; - frame.irisTypingCharIndex = stripMarkup(existingText + linePrefix).length; - frame.irisTypedText = state.rawText; - frame.irisTypingToken = state.revealToken; - frame.irisTypingWaitUntil = null; - } - - if (state.revealToken !== frame.irisTypingToken) return; - - if (state.typingControl === 'stop') { - state.typingControl = null; - state.finalText = stripMarkup(state.rawText); - return; - } - - if (state.typingControl === 'skip') { - while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { - frame.irisTypedText += frame.irisTypingSteps[frame.irisTypingStepIndex++].content; - } - state.rawText = frame.irisTypedText; - state.typingControl = null; - schedulePaint(util.target, state); - flushRenderIfDirty(util.target); - return; - } - - if (frame.irisTypingWaitUntil !== null) { - if (Date.now() < frame.irisTypingWaitUntil) { - util.yieldTick(); - return; - } - frame.irisTypingWaitUntil = null; - } - - while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { - const step = frame.irisTypingSteps[frame.irisTypingStepIndex++]; - frame.irisTypedText += step.content; - if (step.char === null) continue; - - state.rawText = frame.irisTypedText; - schedulePaint(util.target, state); - flushRenderIfDirty(util.target); - startTypingCharacterHat(util.target, step.char, frame.irisTypingCharIndex++); - - const delay = Math.max(0, Number(typingDelayForCharacter(state, step.char)) || 0); - if (delay > 0) { - frame.irisTypingWaitUntil = Date.now() + delay * 1000; - util.yieldTick(); - return; - } - } - - state.rawText = frame.irisTypedText; - schedulePaint(util.target, state); - } - - typeText(args, util) { - this._typeText(args, util, false); - } - - typeNewLine(args, util) { - this._typeText(args, util, true); - } - - skipTyping(args, util) { - getState(util.target).typingControl = 'skip'; - } - - stopTyping(args, util) { - getState(util.target).typingControl = 'stop'; - } - - clearText(args, util) { + } + + _typeText(args, util, appendLine) { + const state = getState(util.target); + const frame = util.stackFrame; + + if (frame.irisTypingSteps === undefined) { + const text = Scratch.Cast.toString(args.TEXT); + const existingText = appendLine && state.visible ? state.rawText : ''; + const linePrefix = appendLine && existingText ? '\n' : ''; + state.rawText = existingText + linePrefix; + state.finalText = stripMarkup(state.rawText + text); + state.visible = true; + if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); + state.revealToken++; + state.typingControl = null; + frame.irisTypingSteps = splitTypingSteps(text); + frame.irisTypingStepIndex = 0; + frame.irisTypingCharIndex = stripMarkup(existingText + linePrefix).length; + frame.irisTypedText = state.rawText; + frame.irisTypingToken = state.revealToken; + frame.irisTypingWaitUntil = null; + } + + if (state.revealToken !== frame.irisTypingToken) return; + + if (state.typingControl === 'stop') { + state.typingControl = null; + state.finalText = stripMarkup(state.rawText); + return; + } + + if (state.typingControl === 'skip') { + while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { + frame.irisTypedText += frame.irisTypingSteps[frame.irisTypingStepIndex++].content; + } + state.rawText = frame.irisTypedText; + state.typingControl = null; + schedulePaint(util.target, state); + flushRenderIfDirty(util.target); + return; + } + + if (frame.irisTypingWaitUntil !== null) { + if (Date.now() < frame.irisTypingWaitUntil) { + util.yieldTick(); + return; + } + frame.irisTypingWaitUntil = null; + } + + while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { + const step = frame.irisTypingSteps[frame.irisTypingStepIndex++]; + frame.irisTypedText += step.content; + if (step.char === null) continue; + + state.rawText = frame.irisTypedText; + schedulePaint(util.target, state); + flushRenderIfDirty(util.target); + startTypingCharacterHat(util.target, step.char, frame.irisTypingCharIndex++); + + const delay = Math.max(0, Number(typingDelayForCharacter(state, step.char)) || 0); + if (delay > 0) { + frame.irisTypingWaitUntil = Date.now() + delay * 1000; + util.yieldTick(); + return; + } + } + + state.rawText = frame.irisTypedText; + schedulePaint(util.target, state); + } + + typeText(args, util) { + this._typeText(args, util, false); + } + + typeNewLine(args, util) { + this._typeText(args, util, true); + } + + skipTyping(args, util) { + getState(util.target).typingControl = 'skip'; + } + + stopTyping(args, util) { + getState(util.target).typingControl = 'stop'; + } + + clearText(args, util) { clearTarget(util.target); } @@ -2790,17 +2536,7 @@ Enjoy!! :D scheduleTextRender(util.target); } - waitForCharsToFinish(args, util) { - if (pendingCharacterThreadStarts.has(util.target)) { - if (pendingRenderTargets.has(util.target)) flushRenderIfDirty(util.target); - else flushPendingRenders(); - } - if (!charThreadsStillActive(util.target)) return; - if (renderFlushScheduled) flushPendingRenders(); - util.yieldTick(); - } - - setBaseColor(args, util) { + setBaseColor(args, util) { const state = getState(util.target); const color = Scratch.Cast.toString(args.COLOR); if (state.baseStyle.color === color) return; @@ -2827,81 +2563,81 @@ Enjoy!! :D schedulePaint(util.target, state); } - getFontMenuItems() { - return buildFontMenuItems(); - } - - getSpriteMenuItems() { - const items = [{ - text: 'myself', - value: '_myself_' - }]; - const names = new Set(); - for (const target of runtime.targets) { - if (target.isStage || target.isOriginal === false) continue; - const name = target.getName(); - if (!names.has(name)) { - names.add(name); - items.push({ - text: name, - value: name - }); - } - } - return items; - } - - exportSettings(args, util) { - return exportTextSettings(getState(util.target)); - } - - applyExportSettings(args, util) { - let settings; - try { - settings = JSON.parse(Scratch.Cast.toString(args.SETTINGS)); - } catch (e) { - return; - } - const sprite = Scratch.Cast.toString(args.SPRITE); - const target = sprite === '_myself_' ? - util.target : - runtime.getSpriteTargetByName(sprite); - if (!target || !applyTextSettings(getState(target), settings)) return; - schedulePaint(target, getState(target)); - } - - setAlign(args, util) { - const state = getState(util.target); - const align = Scratch.Cast.toString(args.ALIGN); - if (state.align === align) return; - state.align = align; - schedulePaint(util.target, state); - } - - setSmoothing(args, util) { - const state = getState(util.target); - const smoothing = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - if (state.smoothing === smoothing) return; - state.smoothing = smoothing; - schedulePaint(util.target, state); - } - - setTypingSpeed(args, util) { - const state = getState(util.target); - const group = Scratch.Cast.toString(args.GROUP); - if (!_hasOwn.call(state.typingSpeeds, group)) return; - state.typingSpeeds[group] = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); - } - - setCustomTypingSpeed(args, util) { - const state = getState(util.target); - const delay = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); - for (const char of Array.from(Scratch.Cast.toString(args.CHARS))) { - state.customTypingSpeeds[char] = delay; - } - } - - setMaxWidth(args, util) { + getFontMenuItems() { + return buildFontMenuItems(); + } + + getSpriteMenuItems() { + const items = [{ + text: 'myself', + value: '_myself_' + }]; + const names = new Set(); + for (const target of runtime.targets) { + if (target.isStage || target.isOriginal === false) continue; + const name = target.getName(); + if (!names.has(name)) { + names.add(name); + items.push({ + text: name, + value: name + }); + } + } + return items; + } + + exportSettings(args, util) { + return exportTextSettings(getState(util.target)); + } + + applyExportSettings(args, util) { + let settings; + try { + settings = JSON.parse(Scratch.Cast.toString(args.SETTINGS)); + } catch (e) { + return; + } + const sprite = Scratch.Cast.toString(args.SPRITE); + const target = sprite === '_myself_' ? + util.target : + runtime.getSpriteTargetByName(sprite); + if (!target || !applyTextSettings(getState(target), settings)) return; + schedulePaint(target, getState(target)); + } + + setAlign(args, util) { + const state = getState(util.target); + const align = Scratch.Cast.toString(args.ALIGN); + if (state.align === align) return; + state.align = align; + schedulePaint(util.target, state); + } + + setSmoothing(args, util) { + const state = getState(util.target); + const smoothing = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + if (state.smoothing === smoothing) return; + state.smoothing = smoothing; + schedulePaint(util.target, state); + } + + setTypingSpeed(args, util) { + const state = getState(util.target); + const group = Scratch.Cast.toString(args.GROUP); + if (!_hasOwn.call(state.typingSpeeds, group)) return; + state.typingSpeeds[group] = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); + } + + setCustomTypingSpeed(args, util) { + const state = getState(util.target); + const delay = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); + for (const char of Array.from(Scratch.Cast.toString(args.CHARS))) { + state.customTypingSpeeds[char] = delay; + } + } + + setMaxWidth(args, util) { const state = getState(util.target); const width = Scratch.Cast.toNumber(args.WIDTH); if (state.maxWidth === width) return; @@ -2935,160 +2671,146 @@ Enjoy!! :D schedulePaint(util.target, state); } - setLetterSpacing(args, util) { + setLetterSpacing(args, util) { const state = getState(util.target); const letterSpacing = Scratch.Cast.toNumber(args.SPACE); if (state.letterSpacing === letterSpacing) return; state.letterSpacing = letterSpacing; - schedulePaint(util.target, state); - } - - setTextShadowEnabled(args, util) { - const state = getState(util.target); - state.textShadow.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - schedulePaint(util.target, state); - } - - setTextShadowColor(args, util) { - const state = getState(util.target); - state.textShadow.color = Scratch.Cast.toString(args.COLOR); - schedulePaint(util.target, state); - } - - setTextShadowOpacity(args, util) { - const state = getState(util.target); - state.textShadow.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); - schedulePaint(util.target, state); - } - - setTextShadowBlur(args, util) { - const state = getState(util.target); - state.textShadow.blur = Math.max(0, Scratch.Cast.toNumber(args.BLUR)); - schedulePaint(util.target, state); - } - - setTextShadowOffset(args, util) { - const state = getState(util.target); - state.textShadow.offsetX = Scratch.Cast.toNumber(args.X); - state.textShadow.offsetY = Scratch.Cast.toNumber(args.Y); - schedulePaint(util.target, state); - } - - setTextBackgroundEnabled(args, util) { - const state = getState(util.target); - state.textBackground.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - schedulePaint(util.target, state); - } - - setTextBackgroundColor(args, util) { - const state = getState(util.target); - state.textBackground.color = Scratch.Cast.toString(args.COLOR); - schedulePaint(util.target, state); - } - - setTextBackgroundOpacity(args, util) { - const state = getState(util.target); - state.textBackground.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); - schedulePaint(util.target, state); - } - - setTextBackgroundRadius(args, util) { - const state = getState(util.target); - state.textBackground.radius = Math.max(0, Scratch.Cast.toNumber(args.RADIUS)); - schedulePaint(util.target, state); - } - - setTextBackgroundPadding(args, util) { - const state = getState(util.target); - state.textBackground.padding = Math.max(0, Scratch.Cast.toNumber(args.PADDING)); - schedulePaint(util.target, state); - } - - setTextBorderEnabled(args, util) { - const state = getState(util.target); - state.textBorder.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - schedulePaint(util.target, state); - } - - setTextBorderColor(args, util) { - const state = getState(util.target); - state.textBorder.color = Scratch.Cast.toString(args.COLOR); - schedulePaint(util.target, state); - } - - setTextBorderOpacity(args, util) { - const state = getState(util.target); - state.textBorder.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); - schedulePaint(util.target, state); - } - - setTextBorderSize(args, util) { - const state = getState(util.target); - state.textBorder.size = Math.max(0, Scratch.Cast.toNumber(args.SIZE)); - schedulePaint(util.target, state); - } - - scheduleTagBlocks(args, util) { - const name = util.target.id; - const blocks = util.thread.target.blocks; - const scheduleBlock = util.thread.peekStack(); - const branch = blocks.getBranch(scheduleBlock, 1); - const tag = Scratch.Cast.toString(args.TAG).toLowerCase(); - - if (charScripts[name] === undefined) { - charScripts[name] = Object.create(null); - } - - if (!branch) { - delete charScripts[name][tag]; - return; - } - - const snapshot = createBlocksSnapshot(blocks, branch, runtime); - const ThreadClass = util.thread ? util.thread.constructor : null; - let compiledResult = null; - - if (ThreadClass) { - const tempThread = new ThreadClass(branch); - tempThread.target = util.target; - tempThread.blockContainer = snapshot; - tempThread.pushStack(branch); - if (typeof tempThread.tryCompile === 'function') { - tempThread.tryCompile(); - if (tempThread.isCompiled) { - compiledResult = snapshot.getCachedCompileResult(branch); - } - } - } - - const newEntry = { - stack: branch, - blocks: snapshot, - target: util.target, - tag, - compiledResult: compiledResult ? (compiledResult.value || compiledResult) : null, - perChar: stackUsesCharReporter(snapshot, branch) - }; - - charScripts[name][tag] = newEntry; - } - - currentCharValue(args, util) { - const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; - return cur ? cur.char : ''; - } - - currentCharIndex(args, util) { - const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; - return cur ? cur.index + 1 : 0; - } - - setResetCharTransformsOnText(args, util) { - const state = getState(util.target); - state.resetCharTransformsOnText = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - } - - setCharPos(args, util) { + schedulePaint(util.target, state); + } + + setTextShadowEnabled(args, util) { + const state = getState(util.target); + state.textShadow.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextShadowColor(args, util) { + const state = getState(util.target); + const color = Scratch.Cast.toString(args.COLOR); + if (state.textShadow.color === color) return; + state.textShadow.color = color; + schedulePaint(util.target, state); + } + + setTextShadowOpacity(args, util) { + const state = getState(util.target); + state.textShadow.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextShadowBlur(args, util) { + const state = getState(util.target); + state.textShadow.blur = Math.max(0, Scratch.Cast.toNumber(args.BLUR)); + schedulePaint(util.target, state); + } + + setTextShadowOffset(args, util) { + const state = getState(util.target); + state.textShadow.offsetX = Scratch.Cast.toNumber(args.X); + state.textShadow.offsetY = Scratch.Cast.toNumber(args.Y); + schedulePaint(util.target, state); + } + + setTextBackgroundEnabled(args, util) { + const state = getState(util.target); + state.textBackground.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextBackgroundColor(args, util) { + const state = getState(util.target); + const color = Scratch.Cast.toString(args.COLOR); + if (state.textBackground.color === color) return; + state.textBackground.color = color; + schedulePaint(util.target, state); + } + + setTextBackgroundOpacity(args, util) { + const state = getState(util.target); + state.textBackground.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextBackgroundRadius(args, util) { + const state = getState(util.target); + state.textBackground.radius = Math.max(0, Scratch.Cast.toNumber(args.RADIUS)); + schedulePaint(util.target, state); + } + + setTextBackgroundPadding(args, util) { + const state = getState(util.target); + state.textBackground.padding = Math.max(0, Scratch.Cast.toNumber(args.PADDING)); + schedulePaint(util.target, state); + } + + setTextBorderEnabled(args, util) { + const state = getState(util.target); + state.textBorder.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextBorderColor(args, util) { + const state = getState(util.target); + const color = Scratch.Cast.toString(args.COLOR); + if (state.textBorder.color === color) return; + state.textBorder.color = color; + schedulePaint(util.target, state); + } + + setTextBorderOpacity(args, util) { + const state = getState(util.target); + state.textBorder.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextBorderSize(args, util) { + const state = getState(util.target); + state.textBorder.size = Math.max(0, Scratch.Cast.toNumber(args.SIZE)); + schedulePaint(util.target, state); + } + + repeatForTag(args, util) { + const frame = util.stackFrame; + const rootFrame = util.thread.stackFrames[0]; + let loop = frame.irisTagLoop; + + if (!loop) { + const tag = Scratch.Cast.toString(args.TAG).toLowerCase(); + loop = { + indices: taggedCharacterIndices(util.target, tag), + position: 0 + }; + frame.irisTagLoop = loop; + rootFrame.irisTagCharacterNumber = 0; + } + + if (loop.position >= loop.indices.length) return; + rootFrame.irisTagCharacterNumber = loop.indices[loop.position] + 1; + loop.position++; + util.startBranch(1, true); + } + + currentCharValue(args, util) { + const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; + return cur ? cur.char : ''; + } + + currentCharIndex(args, util) { + const rootFrame = util.thread && util.thread.stackFrames[0]; + if (rootFrame && rootFrame.irisTagCharacterNumber !== undefined) { + return rootFrame.irisTagCharacterNumber; + } + const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; + return cur ? cur.index + 1 : 0; + } + + setResetCharTransformsOnText(args, util) { + const state = getState(util.target); + state.resetCharTransformsOnText = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + } + + setCharPos(args, util) { const state = getState(util.target); const idx = Scratch.Cast.toNumber(args.INDEX) - 1; const x = Scratch.Cast.toNumber(args.X); @@ -3157,59 +2879,59 @@ Enjoy!! :D } } - resetAllCharTransforms(args, util) { - const state = getState(util.target); - if (Object.keys(state.charOverrides).length) { + resetAllCharTransforms(args, util) { + const state = getState(util.target); + if (Object.keys(state.charOverrides).length) { state.charOverrides = {}; state.charOverridesVersion++; schedulePaint(util.target, state); - } - } - - getCharacterStyle(args, util) { - const state = getState(util.target); - flushRenderIfDirty(util.target); - const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - const box = state.charBoxes[idx]; - return box && box.style ? JSON.stringify(box.style) : ''; - } - - setCharacterStyle(args, util) { - const state = getState(util.target); - const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - flushRenderIfDirty(util.target); - const box = state.charBoxes[idx]; - if (!box || !box.style) return; - - let input; - try { - input = JSON.parse(Scratch.Cast.toString(args.STYLE)); - } catch (e) { - return; - } - const style = characterStyleFromJSON(input, box.style); - if (!style) return; - state.charStyleOverrides[idx] = style; - state.charStyleOverridesVersion++; - state.shapeKey = null; - state.layout = null; - state.paintFingerprint = null; - schedulePaint(util.target, state); - } - - resetCharacterStyle(args, util) { - const state = getState(util.target); - const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - if (!state.charStyleOverrides[idx]) return; - delete state.charStyleOverrides[idx]; - state.charStyleOverridesVersion++; - state.shapeKey = null; - state.layout = null; - state.paintFingerprint = null; - schedulePaint(util.target, state); - } - - getCharX(args, util) { + } + } + + getCharacterStyle(args, util) { + const state = getState(util.target); + flushRenderIfDirty(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const box = state.charBoxes[idx]; + return box && box.style ? JSON.stringify(box.style) : ''; + } + + setCharacterStyle(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + flushRenderIfDirty(util.target); + const box = state.charBoxes[idx]; + if (!box || !box.style) return; + + let input; + try { + input = JSON.parse(Scratch.Cast.toString(args.STYLE)); + } catch (e) { + return; + } + const style = characterStyleFromJSON(input, box.style); + if (!style) return; + state.charStyleOverrides[idx] = style; + state.charStyleOverridesVersion++; + state.shapeKey = null; + state.layout = null; + state.paintFingerprint = null; + schedulePaint(util.target, state); + } + + resetCharacterStyle(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + if (!state.charStyleOverrides[idx]) return; + delete state.charStyleOverrides[idx]; + state.charStyleOverridesVersion++; + state.shapeKey = null; + state.layout = null; + state.paintFingerprint = null; + schedulePaint(util.target, state); + } + + getCharX(args, util) { const state = getState(util.target); flushRenderIfDirty(util.target); const idx = Scratch.Cast.toNumber(args.INDEX) - 1; @@ -3292,4 +3014,4 @@ Enjoy!! :D } Scratch.extensions.register(new IrisText()); -})(Scratch); +})(Scratch); From 66ae96e5a8a6e3fa2b6b8d2671da9d82484c858a Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 18 Aug 2026 19:27:46 -0300 Subject: [PATCH 09/21] new character block --- static/extensions/Gen1x/iris-text.js | 2997 +++++++++++++------------- 1 file changed, 1516 insertions(+), 1481 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 5dddf2aba..1b844e181 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -165,7 +165,7 @@ Enjoy!! :D const TAG_RE = /\[(\/?)([a-z0-9_-]+)(?:=([^\]]+))?\]/gi; const TAG_RE_STRIP = /\[(\/?)([a-z0-9_-]+)(?:=([^\]]+))?\]/gi; - function parseRichText(text, base) { + function parseRichText(text, base) { const stack = [Object.assign({ customTags: [] }, base)]; @@ -245,54 +245,54 @@ Enjoy!! :D } i = match.index + match[0].length; } - return out; - } - - function characterStyleSnapshot(style) { - return { - color: style.color, - bold: style.bold, - italic: style.italic, - underline: style.underline, - strike: style.strike, - size: style.size, - font: style.font, - tags: Array.isArray(style.tags) ? style.tags.slice() : [] - }; - } - - function applyCharacterStyleOverrides(chars, overrides) { - for (const index in overrides) { - const char = chars[index]; - const override = overrides[index]; - if (!char || !override) continue; - char.color = override.color; - char.bold = override.bold; - char.italic = override.italic; - char.underline = override.underline; - char.strike = override.strike; - char.size = override.size; - char.font = override.font; - } - } - - function characterStyleFromJSON(value, base) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const style = characterStyleSnapshot(base); - if (typeof value.color === 'string') style.color = value.color; - if (typeof value.bold === 'boolean') style.bold = value.bold; - if (typeof value.italic === 'boolean') style.italic = value.italic; - if (typeof value.underline === 'boolean') style.underline = value.underline; - if (typeof value.strike === 'boolean') style.strike = value.strike; - if (Number.isFinite(value.size)) style.size = Math.max(1, value.size); - if (typeof value.font === 'string') style.font = value.font; - if (Array.isArray(value.tags) && value.tags.every(tag => typeof tag === 'string')) { - style.tags = value.tags.slice(); - } - return style; - } - - function stripMarkup(text) { + return out; + } + + function characterStyleSnapshot(style) { + return { + color: style.color, + bold: style.bold, + italic: style.italic, + underline: style.underline, + strike: style.strike, + size: style.size, + font: style.font, + tags: Array.isArray(style.tags) ? style.tags.slice() : [] + }; + } + + function applyCharacterStyleOverrides(chars, overrides) { + for (const index in overrides) { + const char = chars[index]; + const override = overrides[index]; + if (!char || !override) continue; + char.color = override.color; + char.bold = override.bold; + char.italic = override.italic; + char.underline = override.underline; + char.strike = override.strike; + char.size = override.size; + char.font = override.font; + } + } + + function characterStyleFromJSON(value, base) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const style = characterStyleSnapshot(base); + if (typeof value.color === 'string') style.color = value.color; + if (typeof value.bold === 'boolean') style.bold = value.bold; + if (typeof value.italic === 'boolean') style.italic = value.italic; + if (typeof value.underline === 'boolean') style.underline = value.underline; + if (typeof value.strike === 'boolean') style.strike = value.strike; + if (Number.isFinite(value.size)) style.size = Math.max(1, value.size); + if (typeof value.font === 'string') style.font = value.font; + if (Array.isArray(value.tags) && value.tags.every(tag => typeof tag === 'string')) { + style.tags = value.tags.slice(); + } + return style; + } + + function stripMarkup(text) { TAG_RE_STRIP.lastIndex = 0; return text.replace(TAG_RE_STRIP, ''); } @@ -334,53 +334,53 @@ Enjoy!! :D return stages; } - function defaultState() { - return { + function defaultState() { + return { rawText: '', finalText: '', visible: false, baseStyle: defaultBaseStyle(), - align: 'center', - smoothing: true, - letterSpacing: 0, - lineSpacing: 1.2, - typingSpeeds: { - default: 0.05, - symbols: 0.05, - punctuation: 0.15, - numbers: 0.05 - }, - customTypingSpeeds: {}, - maxWidth: null, - maxHeight: null, - textShadow: { - enabled: false, - color: '#000000', - opacity: 50, - blur: 8, - offsetX: 4, - offsetY: 4 - }, - textBackground: { - enabled: false, - color: '#000000', - opacity: 50, - radius: 8, - padding: 8 - }, - textBorder: { - enabled: false, - color: '#000000', - opacity: 100, - size: 2 - }, - charOverrides: {}, - charOverridesVersion: 0, - resetCharTransformsOnText: true, - charStyleOverrides: {}, - charStyleOverridesVersion: 0, - typingControl: null, - skinId: null, + align: 'center', + smoothing: true, + letterSpacing: 0, + lineSpacing: 1.2, + typingSpeeds: { + default: 0.05, + symbols: 0.05, + punctuation: 0.15, + numbers: 0.05 + }, + customTypingSpeeds: {}, + maxWidth: null, + maxHeight: null, + textShadow: { + enabled: false, + color: '#000000', + opacity: 50, + blur: 8, + offsetX: 4, + offsetY: 4 + }, + textBackground: { + enabled: false, + color: '#000000', + opacity: 50, + radius: 8, + padding: 8 + }, + textBorder: { + enabled: false, + color: '#000000', + opacity: 100, + size: 2 + }, + charOverrides: {}, + charOverridesVersion: 0, + resetCharTransformsOnText: true, + charStyleOverrides: {}, + charStyleOverridesVersion: 0, + typingControl: null, + skinId: null, drawableWidth: 0, drawableHeight: 0, charBoxes: [], @@ -408,168 +408,168 @@ Enjoy!! :D base.strike ? 1 : 0, base.size, base.font, - state.letterSpacing, - state.lineSpacing, - state.maxWidth || 0, - state.maxHeight || 0, - state.charStyleOverridesVersion - ].join('\u0001'); + state.letterSpacing, + state.lineSpacing, + state.maxWidth || 0, + state.maxHeight || 0, + state.charStyleOverridesVersion + ].join('\u0001'); + } + + function getState(target) { + let state = target.getCustomState(STATE_KEY); + if (!state) { + state = defaultState(); + target.setCustomState(STATE_KEY, state); + } else { + const defaults = defaultState(); + state.textShadow = Object.assign(defaults.textShadow, state.textShadow || {}); + state.textBackground = Object.assign(defaults.textBackground, state.textBackground || {}); + state.textBorder = Object.assign(defaults.textBorder, state.textBorder || {}); + state.typingSpeeds = Object.assign(defaults.typingSpeeds, state.typingSpeeds || {}); + state.customTypingSpeeds = Object.assign({}, state.customTypingSpeeds || {}); + if (typeof state.smoothing !== 'boolean') state.smoothing = defaults.smoothing; + state.charStyleOverrides = Object.assign({}, state.charStyleOverrides || {}); + if (typeof state.resetCharTransformsOnText !== 'boolean') state.resetCharTransformsOnText = defaults.resetCharTransformsOnText; + if (typeof state.charStyleOverridesVersion !== 'number') state.charStyleOverridesVersion = 0; + if (state.typingControl !== 'skip' && state.typingControl !== 'stop') state.typingControl = null; + } + return state; + } + + const TYPING_PUNCTUATION = '.,!?;:\'"…'; + const TYPING_SYMBOLS = '@#$%^&*+=<>/\\|~`_-()[]{}'; + + function typingGroupForCharacter(char) { + if (/^[0-9]$/.test(char)) return 'numbers'; + if (TYPING_PUNCTUATION.includes(char)) return 'punctuation'; + if (TYPING_SYMBOLS.includes(char)) return 'symbols'; + return 'default'; + } + + function typingDelayForCharacter(state, char) { + if (_hasOwn.call(state.customTypingSpeeds, char)) return state.customTypingSpeeds[char]; + return state.typingSpeeds[typingGroupForCharacter(char)]; + } + + function splitTypingSteps(text) { + const steps = []; + TAG_RE.lastIndex = 0; + let lastIndex = 0; + let match; + + const addCharacters = value => { + for (const char of Array.from(value)) { + steps.push({ + content: char, + char + }); + } + }; + + while ((match = TAG_RE.exec(text)) !== null) { + addCharacters(text.slice(lastIndex, match.index)); + steps.push({ + content: match[0], + char: null + }); + lastIndex = match.index + match[0].length; + } + addCharacters(text.slice(lastIndex)); + return steps; + } + + function exportTextSettings(state) { + return JSON.stringify({ + version: 1, + baseStyle: { + color: state.baseStyle.color, + bold: state.baseStyle.bold, + italic: state.baseStyle.italic, + underline: state.baseStyle.underline, + strike: state.baseStyle.strike, + size: state.baseStyle.size, + font: state.baseStyle.font + }, + align: state.align, + smoothing: state.smoothing, + resetCharTransformsOnText: state.resetCharTransformsOnText, + letterSpacing: state.letterSpacing, + lineSpacing: state.lineSpacing, + typingSpeeds: Object.assign({}, state.typingSpeeds), + customTypingSpeeds: Object.assign({}, state.customTypingSpeeds), + maxWidth: state.maxWidth, + maxHeight: state.maxHeight, + textShadow: Object.assign({}, state.textShadow), + textBackground: Object.assign({}, state.textBackground), + textBorder: Object.assign({}, state.textBorder) + }); + } + + function applyTextSettings(state, settings) { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false; + + const base = settings.baseStyle; + if (base && typeof base === 'object' && !Array.isArray(base)) { + if (typeof base.color === 'string') state.baseStyle.color = base.color; + if (typeof base.bold === 'boolean') state.baseStyle.bold = base.bold; + if (typeof base.italic === 'boolean') state.baseStyle.italic = base.italic; + if (typeof base.underline === 'boolean') state.baseStyle.underline = base.underline; + if (typeof base.strike === 'boolean') state.baseStyle.strike = base.strike; + if (Number.isFinite(base.size)) state.baseStyle.size = Math.max(1, base.size); + if (typeof base.font === 'string') state.baseStyle.font = base.font; + } + if (settings.align === 'left' || settings.align === 'center' || settings.align === 'right' || settings.align === 'justify') { + state.align = settings.align; + } + if (typeof settings.smoothing === 'boolean') state.smoothing = settings.smoothing; + if (typeof settings.resetCharTransformsOnText === 'boolean') state.resetCharTransformsOnText = settings.resetCharTransformsOnText; + if (Number.isFinite(settings.letterSpacing)) state.letterSpacing = settings.letterSpacing; + if (Number.isFinite(settings.lineSpacing)) state.lineSpacing = Math.max(0, settings.lineSpacing); + if (settings.typingSpeeds && typeof settings.typingSpeeds === 'object' && !Array.isArray(settings.typingSpeeds)) { + for (const group of ['default', 'symbols', 'punctuation', 'numbers']) { + if (Number.isFinite(settings.typingSpeeds[group])) { + state.typingSpeeds[group] = Math.max(0, settings.typingSpeeds[group]); + } + } + } + if (settings.customTypingSpeeds && typeof settings.customTypingSpeeds === 'object' && !Array.isArray(settings.customTypingSpeeds)) { + state.customTypingSpeeds = {}; + for (const char in settings.customTypingSpeeds) { + if (Number.isFinite(settings.customTypingSpeeds[char])) { + state.customTypingSpeeds[char] = Math.max(0, settings.customTypingSpeeds[char]); + } + } + } + if (settings.maxWidth === null || Number.isFinite(settings.maxWidth)) state.maxWidth = settings.maxWidth; + if (settings.maxHeight === null || Number.isFinite(settings.maxHeight)) state.maxHeight = settings.maxHeight; + + const copySettings = (source, target, keys) => { + if (!source || typeof source !== 'object' || Array.isArray(source)) return; + for (const key of keys) { + if (typeof target[key] === 'boolean' && typeof source[key] === 'boolean') { + target[key] = source[key]; + } else if (typeof target[key] === 'number' && Number.isFinite(source[key])) { + target[key] = source[key]; + } else if (typeof target[key] === 'string' && typeof source[key] === 'string') { + target[key] = source[key]; + } + } + }; + + copySettings(settings.textShadow, state.textShadow, ['enabled', 'color', 'opacity', 'blur', 'offsetX', 'offsetY']); + copySettings(settings.textBackground, state.textBackground, ['enabled', 'color', 'opacity', 'radius', 'padding']); + copySettings(settings.textBorder, state.textBorder, ['enabled', 'color', 'opacity', 'size']); + state.shapeKey = null; + state.layoutKey = null; + state.layout = null; + state.paintDirty = true; + state.paintFingerprint = null; + return true; } - function getState(target) { - let state = target.getCustomState(STATE_KEY); - if (!state) { - state = defaultState(); - target.setCustomState(STATE_KEY, state); - } else { - const defaults = defaultState(); - state.textShadow = Object.assign(defaults.textShadow, state.textShadow || {}); - state.textBackground = Object.assign(defaults.textBackground, state.textBackground || {}); - state.textBorder = Object.assign(defaults.textBorder, state.textBorder || {}); - state.typingSpeeds = Object.assign(defaults.typingSpeeds, state.typingSpeeds || {}); - state.customTypingSpeeds = Object.assign({}, state.customTypingSpeeds || {}); - if (typeof state.smoothing !== 'boolean') state.smoothing = defaults.smoothing; - state.charStyleOverrides = Object.assign({}, state.charStyleOverrides || {}); - if (typeof state.resetCharTransformsOnText !== 'boolean') state.resetCharTransformsOnText = defaults.resetCharTransformsOnText; - if (typeof state.charStyleOverridesVersion !== 'number') state.charStyleOverridesVersion = 0; - if (state.typingControl !== 'skip' && state.typingControl !== 'stop') state.typingControl = null; - } - return state; - } - - const TYPING_PUNCTUATION = '.,!?;:\'"…'; - const TYPING_SYMBOLS = '@#$%^&*+=<>/\\|~`_-()[]{}'; - - function typingGroupForCharacter(char) { - if (/^[0-9]$/.test(char)) return 'numbers'; - if (TYPING_PUNCTUATION.includes(char)) return 'punctuation'; - if (TYPING_SYMBOLS.includes(char)) return 'symbols'; - return 'default'; - } - - function typingDelayForCharacter(state, char) { - if (_hasOwn.call(state.customTypingSpeeds, char)) return state.customTypingSpeeds[char]; - return state.typingSpeeds[typingGroupForCharacter(char)]; - } - - function splitTypingSteps(text) { - const steps = []; - TAG_RE.lastIndex = 0; - let lastIndex = 0; - let match; - - const addCharacters = value => { - for (const char of Array.from(value)) { - steps.push({ - content: char, - char - }); - } - }; - - while ((match = TAG_RE.exec(text)) !== null) { - addCharacters(text.slice(lastIndex, match.index)); - steps.push({ - content: match[0], - char: null - }); - lastIndex = match.index + match[0].length; - } - addCharacters(text.slice(lastIndex)); - return steps; - } - - function exportTextSettings(state) { - return JSON.stringify({ - version: 1, - baseStyle: { - color: state.baseStyle.color, - bold: state.baseStyle.bold, - italic: state.baseStyle.italic, - underline: state.baseStyle.underline, - strike: state.baseStyle.strike, - size: state.baseStyle.size, - font: state.baseStyle.font - }, - align: state.align, - smoothing: state.smoothing, - resetCharTransformsOnText: state.resetCharTransformsOnText, - letterSpacing: state.letterSpacing, - lineSpacing: state.lineSpacing, - typingSpeeds: Object.assign({}, state.typingSpeeds), - customTypingSpeeds: Object.assign({}, state.customTypingSpeeds), - maxWidth: state.maxWidth, - maxHeight: state.maxHeight, - textShadow: Object.assign({}, state.textShadow), - textBackground: Object.assign({}, state.textBackground), - textBorder: Object.assign({}, state.textBorder) - }); - } - - function applyTextSettings(state, settings) { - if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false; - - const base = settings.baseStyle; - if (base && typeof base === 'object' && !Array.isArray(base)) { - if (typeof base.color === 'string') state.baseStyle.color = base.color; - if (typeof base.bold === 'boolean') state.baseStyle.bold = base.bold; - if (typeof base.italic === 'boolean') state.baseStyle.italic = base.italic; - if (typeof base.underline === 'boolean') state.baseStyle.underline = base.underline; - if (typeof base.strike === 'boolean') state.baseStyle.strike = base.strike; - if (Number.isFinite(base.size)) state.baseStyle.size = Math.max(1, base.size); - if (typeof base.font === 'string') state.baseStyle.font = base.font; - } - if (settings.align === 'left' || settings.align === 'center' || settings.align === 'right' || settings.align === 'justify') { - state.align = settings.align; - } - if (typeof settings.smoothing === 'boolean') state.smoothing = settings.smoothing; - if (typeof settings.resetCharTransformsOnText === 'boolean') state.resetCharTransformsOnText = settings.resetCharTransformsOnText; - if (Number.isFinite(settings.letterSpacing)) state.letterSpacing = settings.letterSpacing; - if (Number.isFinite(settings.lineSpacing)) state.lineSpacing = Math.max(0, settings.lineSpacing); - if (settings.typingSpeeds && typeof settings.typingSpeeds === 'object' && !Array.isArray(settings.typingSpeeds)) { - for (const group of ['default', 'symbols', 'punctuation', 'numbers']) { - if (Number.isFinite(settings.typingSpeeds[group])) { - state.typingSpeeds[group] = Math.max(0, settings.typingSpeeds[group]); - } - } - } - if (settings.customTypingSpeeds && typeof settings.customTypingSpeeds === 'object' && !Array.isArray(settings.customTypingSpeeds)) { - state.customTypingSpeeds = {}; - for (const char in settings.customTypingSpeeds) { - if (Number.isFinite(settings.customTypingSpeeds[char])) { - state.customTypingSpeeds[char] = Math.max(0, settings.customTypingSpeeds[char]); - } - } - } - if (settings.maxWidth === null || Number.isFinite(settings.maxWidth)) state.maxWidth = settings.maxWidth; - if (settings.maxHeight === null || Number.isFinite(settings.maxHeight)) state.maxHeight = settings.maxHeight; - - const copySettings = (source, target, keys) => { - if (!source || typeof source !== 'object' || Array.isArray(source)) return; - for (const key of keys) { - if (typeof target[key] === 'boolean' && typeof source[key] === 'boolean') { - target[key] = source[key]; - } else if (typeof target[key] === 'number' && Number.isFinite(source[key])) { - target[key] = source[key]; - } else if (typeof target[key] === 'string' && typeof source[key] === 'string') { - target[key] = source[key]; - } - } - }; - - copySettings(settings.textShadow, state.textShadow, ['enabled', 'color', 'opacity', 'blur', 'offsetX', 'offsetY']); - copySettings(settings.textBackground, state.textBackground, ['enabled', 'color', 'opacity', 'radius', 'padding']); - copySettings(settings.textBorder, state.textBorder, ['enabled', 'color', 'opacity', 'size']); - state.shapeKey = null; - state.layoutKey = null; - state.layout = null; - state.paintDirty = true; - state.paintFingerprint = null; - return true; - } - - function getCharOverride(state, index) { - if (!state.charOverrides[index]) { + function getCharOverride(state, index) { + if (!state.charOverrides[index]) { state.charOverrides[index] = { x: 0, y: 0, @@ -578,14 +578,14 @@ Enjoy!! :D }; state.charOverridesVersion++; } - return state.charOverrides[index]; - } - - function resetCharacterTransformsState(state) { - if (!Object.keys(state.charOverrides).length) return; - state.charOverrides = {}; - state.charOverridesVersion++; - } + return state.charOverrides[index]; + } + + function resetCharacterTransformsState(state) { + if (!Object.keys(state.charOverrides).length) return; + state.charOverrides = {}; + state.charOverridesVersion++; + } const SVG_NS = 'http://www.w3.org/2000/svg'; let measureSvg = null; @@ -715,53 +715,53 @@ Enjoy!! :D fontAscentEm: shape.fontAscentEm, fontDescentEm: shape.fontDescentEm }; - } - - function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { - const stroke = (x1, x2) => { - if (x2 <= x1) return; - ctx.beginPath(); - ctx.moveTo(x1, y); - ctx.lineTo(x2, y); - ctx.stroke(); - }; - - try { - const image = glyph.canvas.getContext('2d').getImageData(0, 0, glyph.canvas.width, glyph.canvas.height); - const rowStart = Math.max(0, Math.floor(y - glyphY - lineWidth / 2)); - const rowEnd = Math.min(glyph.canvas.height - 1, Math.ceil(y - glyphY + lineWidth / 2)); - const columnStart = Math.max(0, Math.floor(startX - glyphX)); - const columnEnd = Math.min(glyph.canvas.width - 1, Math.ceil(endX - glyphX)); - const ink = new Uint8Array(glyph.canvas.width); - - for (let py = rowStart; py <= rowEnd; py++) { - for (let px = columnStart; px <= columnEnd; px++) { - if (image.data[(py * glyph.canvas.width + px) * 4 + 3] > 16) ink[px] = 1; - } - } - - const padding = Math.max(1, Math.ceil(lineWidth / 2) + 1); - let segmentStart = startX; - let px = columnStart; - while (px <= columnEnd) { - if (!ink[px]) { - px++; - continue; - } - const inkStart = px; - while (px <= columnEnd && ink[px]) px++; - const skipStart = Math.max(startX, glyphX + inkStart - padding); - const skipEnd = Math.min(endX, glyphX + px + padding); - stroke(segmentStart, skipStart); - segmentStart = Math.max(segmentStart, skipEnd); - } - stroke(segmentStart, endX); - } catch (e) { - stroke(startX, endX); - } - } - - function invalidateGlyphCacheForFamily(fontFamily) { + } + + function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { + const stroke = (x1, x2) => { + if (x2 <= x1) return; + ctx.beginPath(); + ctx.moveTo(x1, y); + ctx.lineTo(x2, y); + ctx.stroke(); + }; + + try { + const image = glyph.canvas.getContext('2d').getImageData(0, 0, glyph.canvas.width, glyph.canvas.height); + const rowStart = Math.max(0, Math.floor(y - glyphY - lineWidth / 2)); + const rowEnd = Math.min(glyph.canvas.height - 1, Math.ceil(y - glyphY + lineWidth / 2)); + const columnStart = Math.max(0, Math.floor(startX - glyphX)); + const columnEnd = Math.min(glyph.canvas.width - 1, Math.ceil(endX - glyphX)); + const ink = new Uint8Array(glyph.canvas.width); + + for (let py = rowStart; py <= rowEnd; py++) { + for (let px = columnStart; px <= columnEnd; px++) { + if (image.data[(py * glyph.canvas.width + px) * 4 + 3] > 16) ink[px] = 1; + } + } + + const padding = Math.max(1, Math.ceil(lineWidth / 2) + 1); + let segmentStart = startX; + let px = columnStart; + while (px <= columnEnd) { + if (!ink[px]) { + px++; + continue; + } + const inkStart = px; + while (px <= columnEnd && ink[px]) px++; + const skipStart = Math.max(startX, glyphX + inkStart - padding); + const skipEnd = Math.min(endX, glyphX + px + padding); + stroke(segmentStart, skipStart); + segmentStart = Math.max(segmentStart, skipEnd); + } + stroke(segmentStart, endX); + } catch (e) { + stroke(startX, endX); + } + } + + function invalidateGlyphCacheForFamily(fontFamily) { for (const key of glyphCache.keys()) { if (key.indexOf('\u0001' + fontFamily + '\u0001') !== -1) glyphCache.delete(key); } @@ -966,24 +966,24 @@ Enjoy!! :D for (let li = 0; li < lines.length; li++) { const line = lines[li]; - const runs = splitShapingRuns(line, state.charOverrides, globalIndex); - - let runStartX = 0; - for (const run of runs) { - run.fontStyle = svgFontStyle(run.style); - run.runStartX = runStartX; - run.positions = []; - let charX = 0; - for (let i = 0; i < run.chars.length; i++) { - const w = run.chars[i]._width; - run.positions.push({ - shapedX: charX, - advance: w - }); - addTagIndex(charsByTag, run.startIndex + i, run.chars[i].tags); - charX += w; - runStartX += w; - } + const runs = splitShapingRuns(line, state.charOverrides, globalIndex); + + let runStartX = 0; + for (const run of runs) { + run.fontStyle = svgFontStyle(run.style); + run.runStartX = runStartX; + run.positions = []; + let charX = 0; + for (let i = 0; i < run.chars.length; i++) { + const w = run.chars[i]._width; + run.positions.push({ + shapedX: charX, + advance: w + }); + addTagIndex(charsByTag, run.startIndex + i, run.chars[i].tags); + charX += w; + runStartX += w; + } run.runWidth = runStartX - run.runStartX; } @@ -1036,7 +1036,7 @@ Enjoy!! :D return h; } - const pendingRenderTargets = new Set(); + const pendingRenderTargets = new Set(); let renderFlushScheduled = false; function requestRenderFlush() { @@ -1050,34 +1050,34 @@ Enjoy!! :D requestRenderFlush(); } - function flushPendingRenders() { - renderFlushScheduled = false; - const renderTargets = new Set(pendingRenderTargets); - for (const target of renderTargets) { - pendingRenderTargets.delete(target); - const state = getState(target); - if (state.visible) renderTarget(target); - } - if (pendingRenderTargets.size) requestRenderFlush(); + function flushPendingRenders() { + renderFlushScheduled = false; + const renderTargets = new Set(pendingRenderTargets); + for (const target of renderTargets) { + pendingRenderTargets.delete(target); + const state = getState(target); + if (state.visible) renderTarget(target); + } + if (pendingRenderTargets.size) requestRenderFlush(); } function flushRenderIfDirty(target) { if (!pendingRenderTargets.has(target)) return; - pendingRenderTargets.delete(target); - const state = getState(target); - if (state.visible) renderTarget(target); - if (pendingRenderTargets.size) requestRenderFlush(); - } - - function scheduleTextRender(target) { - scheduleRender(target); + pendingRenderTargets.delete(target); + const state = getState(target); + if (state.visible) renderTarget(target); + if (pendingRenderTargets.size) requestRenderFlush(); + } + + function scheduleTextRender(target) { + scheduleRender(target); } - function schedulePaint(target, state) { - state.paintDirty = true; - state.paintFingerprint = null; - if (state.visible) scheduleRender(target); - } + function schedulePaint(target, state) { + state.paintDirty = true; + state.paintFingerprint = null; + if (state.visible) scheduleRender(target); + } function getLayoutKey(state) { return getShapeKey(state) + '\u0002' + state.charOverridesVersion; @@ -1108,11 +1108,11 @@ Enjoy!! :D if (!state.visible) return; const shapeKey = getShapeKey(state); - let layout = state.layout; - if (state.shapeKey !== shapeKey || !layout) { - const richChars = parseRichText(state.rawText, state.baseStyle); - applyCharacterStyleOverrides(richChars, state.charStyleOverrides); - for (const family of new Set(richChars.map(rc => rc.font))) { + let layout = state.layout; + if (state.shapeKey !== shapeKey || !layout) { + const richChars = parseRichText(state.rawText, state.baseStyle); + applyCharacterStyleOverrides(richChars, state.charStyleOverrides); + for (const family of new Set(richChars.map(rc => rc.font))) { ensureDocumentFont(family); } const svg = getMeasureSvg(); @@ -1132,19 +1132,19 @@ Enjoy!! :D const lineHeight = state.baseStyle.size * state.lineSpacing; const totalHeight = lines.length * lineHeight; - const decorationPad = Math.max( - 0, - Number(state.textBackground.padding) || 0, - Number(state.textBorder.size) || 0, - (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetX) || 0), - (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetY) || 0) - ); - const pad = Math.max(64, state.baseStyle.size * 2, decorationPad + state.baseStyle.size); + const decorationPad = Math.max( + 0, + Number(state.textBackground.padding) || 0, + Number(state.textBorder.size) || 0, + (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetX) || 0), + (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetY) || 0) + ); + const pad = Math.max(64, state.baseStyle.size * 2, decorationPad + state.baseStyle.size); const effectiveMaxWidth = state.maxWidth && state.maxWidth > 0 ? state.maxWidth : maxWidth; const effectiveMaxHeight = state.maxHeight && state.maxHeight > 0 ? state.maxHeight : totalHeight; - const docW = Math.ceil(effectiveMaxWidth + pad * 2); + const docW = Math.ceil(effectiveMaxWidth + pad * 2); const docH = Math.ceil(effectiveMaxHeight + pad * 2); const originX = docW / 2; @@ -1160,32 +1160,32 @@ Enjoy!! :D const paintOps = []; const letterSpacing = state.letterSpacing || 0; - for (let li = 0; li < lines.length; li++) { - const line = lines[li]; - const runs = lineRuns[li]; - const lineW = lineWidths[li]; - const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && - effectiveMaxWidth > lineW ? - line.filter(rc => rc.char === ' ').length : 0; - const justifyExtra = justifySpaceCount > 0 ? - (effectiveMaxWidth - lineW) / justifySpaceCount : 0; - let lineX; - - if (state.align === 'left') { - lineX = originX - effectiveMaxWidth / 2; - } else if (state.align === 'right') { - lineX = originX + effectiveMaxWidth / 2 - lineW; - } else if (state.align === 'justify') { - lineX = originX - effectiveMaxWidth / 2; - } else { - lineX = originX - lineW / 2; - } + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + const runs = lineRuns[li]; + const lineW = lineWidths[li]; + const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && + effectiveMaxWidth > lineW ? + line.filter(rc => rc.char === ' ').length : 0; + const justifyExtra = justifySpaceCount > 0 ? + (effectiveMaxWidth - lineW) / justifySpaceCount : 0; + let lineX; + + if (state.align === 'left') { + lineX = originX - effectiveMaxWidth / 2; + } else if (state.align === 'right') { + lineX = originX + effectiveMaxWidth / 2 - lineW; + } else if (state.align === 'justify') { + lineX = originX - effectiveMaxWidth / 2; + } else { + lineX = originX - lineW / 2; + } const textStartY = originY - totalHeight / 2; - const baseCenterY = textStartY + (li * lineHeight) + lineHeight * 0.5; - - let spacesBefore = 0; - for (const run of runs) { + const baseCenterY = textStartY + (li * lineHeight) + lineHeight * 0.5; + + let spacesBefore = 0; + for (const run of runs) { const rc0 = run.chars[0]; const underline = !!rc0.underline; @@ -1197,12 +1197,12 @@ Enjoy!! :D const rc = run.chars[i]; const pos = run.positions[i]; const index = run.startIndex + i; - const o = run.hasOverride ? - (state.charOverrides[index] || EMPTY_OVERRIDE) : - EMPTY_OVERRIDE; - - const charCenterX = runOriginX + pos.shapedX + pos.advance / 2 + - spacesBefore * justifyExtra + o.x; + const o = run.hasOverride ? + (state.charOverrides[index] || EMPTY_OVERRIDE) : + EMPTY_OVERRIDE; + + const charCenterX = runOriginX + pos.shapedX + pos.advance / 2 + + spacesBefore * justifyExtra + o.x; const charCenterY = baseCenterY - o.y; const rotation = o.rotation || 0; const opacity = o.opacity == null ? 1 : o.opacity; @@ -1220,25 +1220,25 @@ Enjoy!! :D letterSpacing, underline, strike, - width: pos.advance - }); - - if (rc.char === ' ') spacesBefore++; + width: pos.advance + }); + + if (rc.char === ' ') spacesBefore++; let box = charBoxes[index]; if (!box) { - box = { - index, - char: rc.char, - tags: rc.tags, - width: pos.advance, - height: rc.size, - style: characterStyleSnapshot(rc) - }; - charBoxes[index] = box; - } - box.style = characterStyleSnapshot(rc); - box.x = charCenterX - originX; + box = { + index, + char: rc.char, + tags: rc.tags, + width: pos.advance, + height: rc.size, + style: characterStyleSnapshot(rc) + }; + charBoxes[index] = box; + } + box.style = characterStyleSnapshot(rc); + box.x = charCenterX - originX; box.y = originY - charCenterY; } } @@ -1256,7 +1256,7 @@ Enjoy!! :D } state.paintFingerprint = fingerprint; - const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); + const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); pushCanvasToDrawable(target, canvas, docW, docH); state.paintDirty = false; } @@ -1293,7 +1293,7 @@ Enjoy!! :D const DEST_SCALE = GLYPH_OVERSAMPLE; - function compositeGlyphsToCanvas(state, paintOps, docW, docH, textWidth, textHeight, originX, originY) { + function compositeGlyphsToCanvas(state, paintOps, docW, docH, textWidth, textHeight, originX, originY) { let canvas = state.paintCanvas; if (!canvas) { canvas = document.createElement('canvas'); @@ -1306,29 +1306,29 @@ Enjoy!! :D if (canvas.width !== pixelW || canvas.height !== pixelH) { canvas.width = pixelW; canvas.height = pixelH; - } - const ctx = state.paintCtx; - ctx.imageSmoothingEnabled = !!state.smoothing; - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, pixelW, pixelH); - ctx.textBaseline = 'alphabetic'; - - const background = state.textBackground; - if (background.enabled) { - const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; - const width = textWidth * DEST_SCALE + padding * 2; - const height = textHeight * DEST_SCALE + padding * 2; - const x = originX * DEST_SCALE - width / 2; - const y = originY * DEST_SCALE - height / 2; - ctx.save(); - ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; - ctx.fillStyle = background.color; - const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); - ctx.beginPath(); - ctx.roundRect(x, y, width, height, radius); - ctx.fill(); - ctx.restore(); - } + } + const ctx = state.paintCtx; + ctx.imageSmoothingEnabled = !!state.smoothing; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, pixelW, pixelH); + ctx.textBaseline = 'alphabetic'; + + const background = state.textBackground; + if (background.enabled) { + const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; + const width = textWidth * DEST_SCALE + padding * 2; + const height = textHeight * DEST_SCALE + padding * 2; + const x = originX * DEST_SCALE - width / 2; + const y = originY * DEST_SCALE - height / 2; + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; + ctx.fillStyle = background.color; + const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.fill(); + ctx.restore(); + } for (let i = 0; i < paintOps.length; i++) { const op = paintOps[i]; @@ -1346,71 +1346,71 @@ Enjoy!! :D const drawXEm = op.x - advanceCenterXEm; const drawYEm = (op.y + baselineYFromLineCenter) - glyph.baselineOriginYEm; - const hasOpacity = op.opacity != null && op.opacity !== 1; - const hasRotation = !!op.rotation; - - const drawGlyph = (drawX, drawY) => { - ctx.drawImage(glyph.canvas, drawX, drawY); - }; - - if (state.textShadow.enabled) { - ctx.save(); - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; - ctx.shadowColor = state.textShadow.color; - ctx.shadowBlur = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; - ctx.shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE; - ctx.shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE; - if (!hasRotation) { - drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - } else { - const cx = op.x * DEST_SCALE; - const cy = op.y * DEST_SCALE; - ctx.translate(cx, cy); - ctx.rotate(-op.rotation * Math.PI / 180); - ctx.translate(-cx, -cy); - drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - } - ctx.restore(); - } - - if (op.underline || op.strike) { - ctx.save(); - if (hasOpacity) ctx.globalAlpha = op.opacity; - ctx.strokeStyle = op.color; - ctx.lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; - if (op.strike) { - const sy = op.y * DEST_SCALE; - ctx.beginPath(); - ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); - ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); - ctx.stroke(); - } - ctx.restore(); - } - - if (state.textBorder.enabled && Number(state.textBorder.size) > 0) { - const shape = getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); - const borderGlyph = tintGlyph(shape, state.textBorder.color); - ctx.save(); - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textBorder.opacity) || 0)) / 100; - const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; - const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; - for (const [offsetX, offsetY] of borderOffsets) { - if (!hasRotation) { - ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); - } else { - const cx = op.x * DEST_SCALE; - const cy = op.y * DEST_SCALE; - ctx.save(); - ctx.translate(cx, cy); - ctx.rotate(-op.rotation * Math.PI / 180); - ctx.translate(-cx, -cy); - ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); - ctx.restore(); - } - } - ctx.restore(); - } + const hasOpacity = op.opacity != null && op.opacity !== 1; + const hasRotation = !!op.rotation; + + const drawGlyph = (drawX, drawY) => { + ctx.drawImage(glyph.canvas, drawX, drawY); + }; + + if (state.textShadow.enabled) { + ctx.save(); + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; + ctx.shadowColor = state.textShadow.color; + ctx.shadowBlur = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; + ctx.shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE; + ctx.shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE; + if (!hasRotation) { + drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + } else { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.translate(cx, cy); + ctx.rotate(-op.rotation * Math.PI / 180); + ctx.translate(-cx, -cy); + drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + } + ctx.restore(); + } + + if (op.underline || op.strike) { + ctx.save(); + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + ctx.lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + if (op.strike) { + const sy = op.y * DEST_SCALE; + ctx.beginPath(); + ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); + ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); + ctx.stroke(); + } + ctx.restore(); + } + + if (state.textBorder.enabled && Number(state.textBorder.size) > 0) { + const shape = getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); + const borderGlyph = tintGlyph(shape, state.textBorder.color); + ctx.save(); + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textBorder.opacity) || 0)) / 100; + const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; + const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; + for (const [offsetX, offsetY] of borderOffsets) { + if (!hasRotation) { + ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); + } else { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-op.rotation * Math.PI / 180); + ctx.translate(-cx, -cy); + ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); + ctx.restore(); + } + } + ctx.restore(); + } if (!hasRotation) { const px = Math.round(drawXEm * DEST_SCALE); @@ -1431,40 +1431,40 @@ Enjoy!! :D ctx.translate(cx, cy); ctx.rotate(-op.rotation * Math.PI / 180); ctx.translate(-cx, -cy); - ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - ctx.restore(); - } - - if (op.underline) { - ctx.save(); - if (hasOpacity) ctx.globalAlpha = op.opacity; - ctx.strokeStyle = op.color; - const lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; - ctx.lineWidth = lineWidth; - const uy = (op.y + baselineYFromLineCenter + fontSize * 0.06) * DEST_SCALE; - const startX = (op.x - op.width / 2) * DEST_SCALE; - const endX = (op.x + op.width / 2) * DEST_SCALE; - if (hasRotation) { - ctx.beginPath(); - ctx.moveTo(startX, uy); - ctx.lineTo(endX, uy); - ctx.stroke(); - } else { - strokeUnderline(ctx, glyph, Math.round(drawXEm * DEST_SCALE), Math.round(drawYEm * DEST_SCALE), startX, endX, uy, lineWidth); - } - ctx.restore(); - } - - } + ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + ctx.restore(); + } + + if (op.underline) { + ctx.save(); + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + const lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + ctx.lineWidth = lineWidth; + const uy = (op.y + baselineYFromLineCenter + fontSize * 0.06) * DEST_SCALE; + const startX = (op.x - op.width / 2) * DEST_SCALE; + const endX = (op.x + op.width / 2) * DEST_SCALE; + if (hasRotation) { + ctx.beginPath(); + ctx.moveTo(startX, uy); + ctx.lineTo(endX, uy); + ctx.stroke(); + } else { + strokeUnderline(ctx, glyph, Math.round(drawXEm * DEST_SCALE), Math.round(drawYEm * DEST_SCALE), startX, endX, uy, lineWidth); + } + ctx.restore(); + } + + } return canvas; } - function installLinearFiltering(skin, state) { - skin.useNearest = function(scale) { - if (!state.smoothing) return true; - const s = Math.max(Math.abs(scale[0]), Math.abs(scale[1])); - return s >= (DEST_SCALE * 100 - 5); + function installLinearFiltering(skin, state) { + skin.useNearest = function(scale) { + if (!state.smoothing) return true; + const s = Math.max(Math.abs(scale[0]), Math.abs(scale[1])); + return s >= (DEST_SCALE * 100 - 5); }; } @@ -1475,18 +1475,18 @@ Enjoy!! :D const rotationCenter = [width / 2, height / 2]; if (state.skinId === null) { - const BitmapSkin = renderer.exports.BitmapSkin; - const skinId = renderer._nextSkinId++; - const skin = new BitmapSkin(skinId, renderer); - installLinearFiltering(skin, state); + const BitmapSkin = renderer.exports.BitmapSkin; + const skinId = renderer._nextSkinId++; + const skin = new BitmapSkin(skinId, renderer); + installLinearFiltering(skin, state); skin.setBitmap(canvas, DEST_SCALE, rotationCenter); renderer._allSkins[skinId] = skin; state.skinId = skinId; } else { - renderer.updateBitmapSkin(state.skinId, canvas, DEST_SCALE, rotationCenter); - const current = renderer._allSkins[state.skinId]; - if (current && !Object.prototype.hasOwnProperty.call(current, 'useNearest')) { - installLinearFiltering(current, state); + renderer.updateBitmapSkin(state.skinId, canvas, DEST_SCALE, rotationCenter); + const current = renderer._allSkins[state.skinId]; + if (current && !Object.prototype.hasOwnProperty.call(current, 'useNearest')) { + installLinearFiltering(current, state); } } renderer.updateDrawableSkinId(target.drawableID, state.skinId); @@ -1496,110 +1496,110 @@ Enjoy!! :D function clearTarget(target) { const state = getState(target); state.visible = false; - state.revealToken++; - state.paintDirty = true; - state.paintFingerprint = null; - const costume = target.getCostumes()[target.currentCostume]; + state.revealToken++; + state.paintDirty = true; + state.paintFingerprint = null; + const costume = target.getCostumes()[target.currentCostume]; if (costume && runtime.renderer) { runtime.renderer.updateDrawableSkinId(target.drawableID, costume.skinId); runtime.requestRedraw(); } - } - - function startTypingCharacterHat(target, char, index) { - const current = { - char, - index - }; - target._irisTypingChar = current; - const threads = runtime.startHats('irisText_onCharacterType', null, target); - if (Array.isArray(threads)) { - for (const thread of threads) { - thread._irisChar = current; - } - } - target._irisTypingChar = null; - } - - function taggedCharacterIndices(target, tag) { - const state = getState(target); - const chars = parseRichText(state.rawText, state.baseStyle); - const indices = []; - for (let index = 0; index < chars.length; index++) { - if (chars[index].tags.includes(tag)) indices.push(index); - } - return indices; - } - - class IrisText { + } + + function startTypingCharacterHat(target, char, index) { + const current = { + char, + index + }; + target._irisTypingChar = current; + const threads = runtime.startHats('irisText_onCharacterType', null, target); + if (Array.isArray(threads)) { + for (const thread of threads) { + thread._irisChar = current; + } + } + target._irisTypingChar = null; + } + + function taggedCharacterIndices(target, tag) { + const state = getState(target); + const chars = parseRichText(state.rawText, state.baseStyle); + const indices = []; + for (let index = 0; index < chars.length; index++) { + if (chars[index].tags.includes(tag)) indices.push(index); + } + return indices; + } + + class IrisText { constructor() { this._onTargetRemoved = this._onTargetRemoved.bind(this); runtime.on('targetWasRemoved', this._onTargetRemoved); - runtime.on('PROJECT_STOP_ALL', () => { - runtime.targets.forEach(t => clearTarget(t)); - }); - runtime.ext_irisText = this; - if (typeof runtime.registerCompiledExtensionBlocks === 'function') { - runtime.registerCompiledExtensionBlocks('irisText', this.getCompileInfo()); - } - } - - *_repeatCompiledTag(tagInput, func, thread, target, stage) { - const tag = Scratch.Cast.toString(tagInput).toLowerCase(); - const indices = taggedCharacterIndices(target, tag); - const rootFrame = thread.stackFrames[0]; - rootFrame.irisTagCharacterNumber = 0; - - for (const index of indices) { - rootFrame.irisTagCharacterNumber = index + 1; - yield* func(thread, target, runtime, stage); - } - } - - getCompileInfo() { - return { - ir: { - repeatForTag: (generator, block) => ({ - kind: 'stack', - tag: generator.descendInputOfBlock(block, 'TAG'), - substack: generator.descendSubstack(block, 'SUBSTACK') - }), - currentCharValue: () => ({ - kind: 'input' - }), - currentCharIndex: () => ({ - kind: 'input' - }) - }, - js: { - repeatForTag: (node, compiler, imports) => { - const temp = compiler.source; - compiler.source = '(function*(thread, target, runtime, stage) {\n'; - if (node.substack) { - compiler.descendStack(node.substack, new imports.Frame(false)); - } - compiler.source += '})'; - const funcExpr = compiler.source; - compiler.source = temp; - compiler.source += `yield* runtime.ext_irisText._repeatCompiledTag(${compiler.descendInput(node.tag).asString()}, ${funcExpr}, thread, target, stage);\n`; - }, - currentCharValue: (node, compiler, imports) => { - return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.char : (target && target._irisTypingChar ? target._irisTypingChar.char : ""))', imports.TYPE_STRING); - }, - currentCharIndex: (node, compiler, imports) => { - return new imports.TypedInput('(thread && thread.stackFrames[0] && thread.stackFrames[0].irisTagCharacterNumber !== undefined ? thread.stackFrames[0].irisTagCharacterNumber : (thread && thread._irisChar ? thread._irisChar.index + 1 : (target && target._irisTypingChar ? target._irisTypingChar.index + 1 : 0)))', imports.TYPE_NUMBER); - } - } - }; - } - - _onTargetRemoved(target) { + runtime.on('PROJECT_STOP_ALL', () => { + runtime.targets.forEach(t => clearTarget(t)); + }); + runtime.ext_irisText = this; + if (typeof runtime.registerCompiledExtensionBlocks === 'function') { + runtime.registerCompiledExtensionBlocks('irisText', this.getCompileInfo()); + } + } + + *_repeatCompiledTag(tagInput, func, thread, target, stage) { + const tag = Scratch.Cast.toString(tagInput).toLowerCase(); + const indices = taggedCharacterIndices(target, tag); + const rootFrame = thread.stackFrames[0]; + rootFrame.irisTagCharacterNumber = 0; + + for (const index of indices) { + rootFrame.irisTagCharacterNumber = index + 1; + yield* func(thread, target, runtime, stage); + } + } + + getCompileInfo() { + return { + ir: { + repeatForTag: (generator, block) => ({ + kind: 'stack', + tag: generator.descendInputOfBlock(block, 'TAG'), + substack: generator.descendSubstack(block, 'SUBSTACK') + }), + currentCharValue: () => ({ + kind: 'input' + }), + currentCharIndex: () => ({ + kind: 'input' + }) + }, + js: { + repeatForTag: (node, compiler, imports) => { + const temp = compiler.source; + compiler.source = '(function*(thread, target, runtime, stage) {\n'; + if (node.substack) { + compiler.descendStack(node.substack, new imports.Frame(false)); + } + compiler.source += '})'; + const funcExpr = compiler.source; + compiler.source = temp; + compiler.source += `yield* runtime.ext_irisText._repeatCompiledTag(${compiler.descendInput(node.tag).asString()}, ${funcExpr}, thread, target, stage);\n`; + }, + currentCharValue: (node, compiler, imports) => { + return new imports.TypedInput('(thread && thread._irisChar ? thread._irisChar.char : (target && target._irisTypingChar ? target._irisTypingChar.char : ""))', imports.TYPE_STRING); + }, + currentCharIndex: (node, compiler, imports) => { + return new imports.TypedInput('(thread && thread.stackFrames[0] && thread.stackFrames[0].irisTagCharacterNumber !== undefined ? thread.stackFrames[0].irisTagCharacterNumber : (thread && thread._irisChar ? thread._irisChar.index + 1 : (target && target._irisTypingChar ? target._irisTypingChar.index + 1 : 0)))', imports.TYPE_NUMBER); + } + } + }; + } + + _onTargetRemoved(target) { const state = target.getCustomState(STATE_KEY); if (state && state.skinId !== null && runtime.renderer) { runtime.renderer.destroySkin(state.skinId); state.skinId = null; } - } + } getInfo() { return { @@ -1682,481 +1682,501 @@ Enjoy!! :D } } }, - { - opcode: 'setAlign', - blockType: Scratch.BlockType.COMMAND, + { + opcode: 'setAlign', + blockType: Scratch.BlockType.COMMAND, text: 'align text [ALIGN]', arguments: { ALIGN: { type: Scratch.ArgumentType.STRING, menu: 'ALIGN', defaultValue: 'center' - } - } - }, - { - opcode: 'setSmoothing', - blockType: Scratch.BlockType.COMMAND, - text: 'smoothing [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Typing' - }, - { - opcode: 'typeText', - blockType: Scratch.BlockType.COMMAND, - text: 'type [TEXT]', - arguments: { - TEXT: { - type: Scratch.ArgumentType.STRING, - defaultValue: DEFAULT_TEXT - } - } - }, - { - opcode: 'typeNewLine', - blockType: Scratch.BlockType.COMMAND, - text: 'type new line [TEXT]', - arguments: { - TEXT: { - type: Scratch.ArgumentType.STRING, - defaultValue: DEFAULT_TEXT - } - } - }, - { - opcode: 'skipTyping', - blockType: Scratch.BlockType.COMMAND, - text: 'skip typing' - }, - { - opcode: 'stopTyping', - blockType: Scratch.BlockType.COMMAND, - text: 'stop typing' - }, - { - opcode: 'setTypingSpeed', - blockType: Scratch.BlockType.COMMAND, - text: 'set typing speed for [GROUP] characters to [SECONDS] seconds', - arguments: { - GROUP: { - type: Scratch.ArgumentType.STRING, - menu: 'TYPING_GROUP', - defaultValue: 'default' - }, - SECONDS: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0.05 - } - } - }, - { - opcode: 'setCustomTypingSpeed', - blockType: Scratch.BlockType.COMMAND, - text: 'set typing speed for custom characters [CHARS] to [SECONDS] seconds', - arguments: { - CHARS: { - type: Scratch.ArgumentType.STRING, - defaultValue: '…' - }, - SECONDS: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0.2 - } - } - }, - { - opcode: 'onCharacterType', - blockType: Scratch.BlockType.EVENT, - isEdgeActivated: false, - shouldRestartExistingThreads: false, - text: 'on character type' - }, - { - opcode: 'currentCharValue', - blockType: Scratch.BlockType.REPORTER, - text: 'character' - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Shadow' - }, - { - opcode: 'setTextShadowEnabled', - blockType: Scratch.BlockType.COMMAND, - text: 'text shadow [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setTextShadowColor', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow color to [COLOR]', - arguments: { - COLOR: { - type: Scratch.ArgumentType.COLOR - } - } - }, - { - opcode: 'setTextShadowOpacity', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow opacity to [PCT] %', - arguments: { - PCT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 50 - } - } - }, - { - opcode: 'setTextShadowBlur', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow blur to [BLUR]', - arguments: { - BLUR: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 8 - } - } - }, - { - opcode: 'setTextShadowOffset', - blockType: Scratch.BlockType.COMMAND, - text: 'set shadow offset x [X] y [Y]', - arguments: { - X: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 4 - }, - Y: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 4 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Background' - }, - { - opcode: 'setTextBackgroundEnabled', - blockType: Scratch.BlockType.COMMAND, - text: 'text background [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setTextBackgroundColor', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background color to [COLOR]', - arguments: { - COLOR: { - type: Scratch.ArgumentType.COLOR - } - } - }, - { - opcode: 'setTextBackgroundOpacity', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background opacity to [PCT] %', - arguments: { - PCT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 50 - } - } - }, - { - opcode: 'setTextBackgroundRadius', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background radius to [RADIUS]', - arguments: { - RADIUS: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 8 - } - } - }, - { - opcode: 'setTextBackgroundPadding', - blockType: Scratch.BlockType.COMMAND, - text: 'set text background padding to [PADDING]', - arguments: { - PADDING: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 8 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Border' - }, - { - opcode: 'setTextBorderEnabled', - blockType: Scratch.BlockType.COMMAND, - text: 'text border [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setTextBorderColor', - blockType: Scratch.BlockType.COMMAND, - text: 'set text border color to [COLOR]', - arguments: { - COLOR: { - type: Scratch.ArgumentType.COLOR - } - } - }, - { - opcode: 'setTextBorderOpacity', - blockType: Scratch.BlockType.COMMAND, - text: 'set text border opacity to [PCT] %', - arguments: { - PCT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 100 - } - } - }, - { - opcode: 'setTextBorderSize', - blockType: Scratch.BlockType.COMMAND, - text: 'set text border size to [SIZE]', - arguments: { - SIZE: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 2 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Size Constraints' + } + } }, { - opcode: 'setMaxWidth', + opcode: 'setSmoothing', blockType: Scratch.BlockType.COMMAND, - text: 'set max width to [WIDTH]', + text: 'smoothing [ENABLED]', arguments: { - WIDTH: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' } } }, { - opcode: 'setMaxHeight', + blockType: Scratch.BlockType.LABEL, + text: 'Typing' + }, + { + opcode: 'typeText', blockType: Scratch.BlockType.COMMAND, - text: 'set max height to [HEIGHT]', + text: 'type [TEXT]', arguments: { - HEIGHT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: DEFAULT_TEXT } } }, { - opcode: 'setMaxDimensions', + opcode: 'typeNewLine', blockType: Scratch.BlockType.COMMAND, - text: 'set max width to [WIDTH] and max height to [HEIGHT]', + text: 'type new line [TEXT]', arguments: { - WIDTH: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 - }, - HEIGHT: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 200 + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: DEFAULT_TEXT } } }, { - opcode: 'clearMaxDimensions', + opcode: 'skipTyping', blockType: Scratch.BlockType.COMMAND, - text: 'remove max width and height bounds' + text: 'skip typing' }, - { - opcode: 'setLetterSpacing', - blockType: Scratch.BlockType.COMMAND, - text: 'set letter spacing to [SPACE]', - arguments: { - SPACE: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Settings' - }, - { - opcode: 'exportSettings', - blockType: Scratch.BlockType.REPORTER, - text: 'export settings as JSON' - }, - { - opcode: 'applyExportSettings', - blockType: Scratch.BlockType.COMMAND, - text: 'apply JSON export settings [SETTINGS] to [SPRITE]', - arguments: { - SETTINGS: { - type: Scratch.ArgumentType.STRING, - defaultValue: '{"version":1}' - }, - SPRITE: { - type: Scratch.ArgumentType.STRING, - menu: 'SPRITE', - defaultValue: '_myself_' - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Tag Loops' - }, - { - opcode: 'repeatForTag', - blockType: Scratch.BlockType.LOOP, - text: 'repeat for blocks in tag [TAG] [CHAR]', - branchCount: 1, - arguments: { - TAG: { - type: Scratch.ArgumentType.STRING, - defaultValue: 'shake' - }, - CHAR: { - type: Scratch.ArgumentType.STRING, - fillIn: 'currentCharIndex' - } - } - }, - { - opcode: 'currentCharIndex', - blockType: Scratch.BlockType.REPORTER, - text: 'character #', - canDragDuplicate: true, - hideFromPalette: true - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Character Transforms' - }, - { - opcode: 'setResetCharTransformsOnText', - blockType: Scratch.BlockType.COMMAND, - text: 'reset character transforms on show/type text [ENABLED]', - arguments: { - ENABLED: { - type: Scratch.ArgumentType.STRING, - menu: 'ONOFF', - defaultValue: 'on' - } - } - }, - { - opcode: 'setCharPos', + { + opcode: 'stopTyping', blockType: Scratch.BlockType.COMMAND, - text: 'set character [INDEX] position to x: [X] y: [Y]', + text: 'stop typing' + }, + { + opcode: 'setTypingSpeed', + blockType: Scratch.BlockType.COMMAND, + text: 'set typing speed for [GROUP] characters to [SECONDS] seconds', arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - X: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 + GROUP: { + type: Scratch.ArgumentType.STRING, + menu: 'TYPING_GROUP', + defaultValue: 'default' }, - Y: { + SECONDS: { type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 + defaultValue: 0.05 } } }, { - opcode: 'changeCharPos', + opcode: 'setCustomTypingSpeed', blockType: Scratch.BlockType.COMMAND, - text: 'change character [INDEX] position by x: [X] y: [Y]', + text: 'set typing speed for custom characters [CHARS] to [SECONDS] seconds', arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - X: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 + CHARS: { + type: Scratch.ArgumentType.STRING, + defaultValue: '…' }, - Y: { + SECONDS: { type: Scratch.ArgumentType.NUMBER, - defaultValue: 0 + defaultValue: 0.2 } } }, { - opcode: 'setCharRotation', + opcode: 'onCharacterType', + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: false, + text: 'on character type' + }, + { + opcode: 'currentCharValue', + blockType: Scratch.BlockType.REPORTER, + text: 'character' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Shadow' + }, + { + opcode: 'setTextShadowEnabled', blockType: Scratch.BlockType.COMMAND, - text: 'set character [INDEX] rotation to [DEG] degrees', + text: 'text shadow [ENABLED]', arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - DEG: { - type: Scratch.ArgumentType.ANGLE, - defaultValue: 0 + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' } } }, { - opcode: 'changeCharRotation', + opcode: 'setTextShadowColor', blockType: Scratch.BlockType.COMMAND, - text: 'change character [INDEX] rotation by [DEG] degrees', + text: 'set shadow color to [COLOR]', arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - DEG: { - type: Scratch.ArgumentType.ANGLE, - defaultValue: 0 + COLOR: { + type: Scratch.ArgumentType.COLOR } } }, { - opcode: 'setCharOpacity', + opcode: 'setTextShadowOpacity', blockType: Scratch.BlockType.COMMAND, - text: 'set character [INDEX] opacity to [PCT] %', + text: 'set shadow opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'setTextShadowBlur', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow blur to [BLUR]', + arguments: { + BLUR: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + opcode: 'setTextShadowOffset', + blockType: Scratch.BlockType.COMMAND, + text: 'set shadow offset x [X] y [Y]', + arguments: { + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 4 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 4 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Background' + }, + { + opcode: 'setTextBackgroundEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text background [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextBackgroundColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextBackgroundOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background opacity to [PCT] %', + arguments: { + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'setTextBackgroundRadius', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background radius to [RADIUS]', + arguments: { + RADIUS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + opcode: 'setTextBackgroundPadding', + blockType: Scratch.BlockType.COMMAND, + text: 'set text background padding to [PADDING]', + arguments: { + PADDING: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 8 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Border' + }, + { + opcode: 'setTextBorderEnabled', + blockType: Scratch.BlockType.COMMAND, + text: 'text border [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setTextBorderColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border color to [COLOR]', + arguments: { + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'setTextBorderOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border opacity to [PCT] %', arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'setTextBorderSize', + blockType: Scratch.BlockType.COMMAND, + text: 'set text border size to [SIZE]', + arguments: { + SIZE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 2 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Size Constraints' + }, + { + opcode: 'setMaxWidth', + blockType: Scratch.BlockType.COMMAND, + text: 'set max width to [WIDTH]', + arguments: { + WIDTH: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + } + } + }, + { + opcode: 'setMaxHeight', + blockType: Scratch.BlockType.COMMAND, + text: 'set max height to [HEIGHT]', + arguments: { + HEIGHT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + } + } + }, + { + opcode: 'setMaxDimensions', + blockType: Scratch.BlockType.COMMAND, + text: 'set max width to [WIDTH] and max height to [HEIGHT]', + arguments: { + WIDTH: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + }, + HEIGHT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 200 + } + } + }, + { + opcode: 'clearMaxDimensions', + blockType: Scratch.BlockType.COMMAND, + text: 'remove max width and height bounds' + }, + { + opcode: 'setLetterSpacing', + blockType: Scratch.BlockType.COMMAND, + text: 'set letter spacing to [SPACE]', + arguments: { + SPACE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Settings' + }, + { + opcode: 'exportSettings', + blockType: Scratch.BlockType.REPORTER, + text: 'export settings as JSON' + }, + { + opcode: 'applyExportSettings', + blockType: Scratch.BlockType.COMMAND, + text: 'apply JSON export settings [SETTINGS] to [SPRITE]', + arguments: { + SETTINGS: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"version":1}' + }, + SPRITE: { + type: Scratch.ArgumentType.STRING, + menu: 'SPRITE', + defaultValue: '_myself_' + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Tag Loops' + }, + { + opcode: 'repeatForTag', + blockType: Scratch.BlockType.LOOP, + text: 'repeat for blocks in tag [TAG] [CHAR]', + branchCount: 1, + arguments: { + TAG: { + type: Scratch.ArgumentType.STRING, + defaultValue: 'shake' + }, + CHAR: { + type: Scratch.ArgumentType.STRING, + fillIn: 'currentCharIndex' + } + } + }, + { + opcode: 'currentCharIndex', + blockType: Scratch.BlockType.REPORTER, + text: 'character #', + canDragDuplicate: true, + hideFromPalette: true + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Character Transforms' + }, + { + opcode: 'setResetCharTransformsOnText', + blockType: Scratch.BlockType.COMMAND, + text: 'reset character transforms on show/type text [ENABLED]', + arguments: { + ENABLED: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF', + defaultValue: 'on' + } + } + }, + { + opcode: 'setCharPos', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] position to x: [X] y: [Y]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'changeCharPos', + blockType: Scratch.BlockType.COMMAND, + text: 'change character [INDEX] position by x: [X] y: [Y]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'setCharAxisPos', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] [AXIS] to [VALUE]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + AXIS: { + type: Scratch.ArgumentType.STRING, + menu: 'AXIS', + defaultValue: 'x' + }, + VALUE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'setCharRotation', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] rotation to [DEG] degrees', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + DEG: { + type: Scratch.ArgumentType.ANGLE, + defaultValue: 0 + } + } + }, + { + opcode: 'changeCharRotation', + blockType: Scratch.BlockType.COMMAND, + text: 'change character [INDEX] rotation by [DEG] degrees', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + DEG: { + type: Scratch.ArgumentType.ANGLE, + defaultValue: 0 + } + } + }, + { + opcode: 'setCharOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] opacity to [PCT] %', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, PCT: { type: Scratch.ArgumentType.NUMBER, defaultValue: 100 @@ -2174,55 +2194,55 @@ Enjoy!! :D } } }, - { - opcode: 'resetAllCharTransforms', - blockType: Scratch.BlockType.COMMAND, - text: 'reset all character transforms' - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Character Style' - }, - { - opcode: 'getCharacterStyle', - blockType: Scratch.BlockType.REPORTER, - text: 'style of character [INDEX] as JSON', - arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - } - } - }, - { - opcode: 'setCharacterStyle', - blockType: Scratch.BlockType.COMMAND, - text: 'set style of character [INDEX] to JSON [STYLE]', - arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - }, - STYLE: { - type: Scratch.ArgumentType.STRING, - defaultValue: '{"color":"#9966ff","bold":false,"italic":false,"underline":false,"strike":false,"size":32,"font":"Handwriting","tags":[]}' - } - } - }, - { - opcode: 'resetCharacterStyle', - blockType: Scratch.BlockType.COMMAND, - text: 'reset style of character [INDEX]', - arguments: { - INDEX: { - type: Scratch.ArgumentType.NUMBER, - defaultValue: 1 - } - } - }, - { - blockType: Scratch.BlockType.LABEL, - text: 'Text Info' + { + opcode: 'resetAllCharTransforms', + blockType: Scratch.BlockType.COMMAND, + text: 'reset all character transforms' + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Character Style' + }, + { + opcode: 'getCharacterStyle', + blockType: Scratch.BlockType.REPORTER, + text: 'style of character [INDEX] as JSON', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'setCharacterStyle', + blockType: Scratch.BlockType.COMMAND, + text: 'set style of character [INDEX] to JSON [STYLE]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + STYLE: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"color":"#9966ff","bold":false,"italic":false,"underline":false,"strike":false,"size":32,"font":"Handwriting","tags":[]}' + } + } + }, + { + opcode: 'resetCharacterStyle', + blockType: Scratch.BlockType.COMMAND, + text: 'reset style of character [INDEX]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Text Info' }, { opcode: 'getCharX', @@ -2364,40 +2384,43 @@ Enjoy!! :D } } ], - menus: { - ONOFF: { - items: ['on', 'off'] - }, - ALIGN: { - items: ['left', 'center', 'right', 'justify'] - }, - STYLE: { - items: ['bold', 'italic', 'underline', 'strikethrough'] - }, - TYPING_GROUP: { - items: ['default', 'symbols', 'punctuation', 'numbers'] - }, - FONT: { - items: 'getFontMenuItems', - isTypeable: true - }, - SPRITE: { - items: 'getSpriteMenuItems' - } - } - }; - } - - setText(args, util) { - const state = getState(util.target); + menus: { + ONOFF: { + items: ['on', 'off'] + }, + ALIGN: { + items: ['left', 'center', 'right', 'justify'] + }, + STYLE: { + items: ['bold', 'italic', 'underline', 'strikethrough'] + }, + TYPING_GROUP: { + items: ['default', 'symbols', 'punctuation', 'numbers'] + }, + FONT: { + items: 'getFontMenuItems', + isTypeable: true + }, + SPRITE: { + items: 'getSpriteMenuItems' + }, + AXIS: { + items: ['x', 'y'] + } + } + }; + } + + setText(args, util) { + const state = getState(util.target); const frame = util.stackFrame; - if (frame.irisStages === undefined) { - state.rawText = Scratch.Cast.toString(args.TEXT); - state.finalText = stripMarkup(state.rawText); - state.visible = true; - if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); - frame.irisStages = splitWaitStages(state.rawText); + if (frame.irisStages === undefined) { + state.rawText = Scratch.Cast.toString(args.TEXT); + state.finalText = stripMarkup(state.rawText); + state.visible = true; + if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); + frame.irisStages = splitWaitStages(state.rawText); frame.irisStageIndex = 0; frame.irisToken = ++state.revealToken; frame.irisWaitUntil = null; @@ -2421,8 +2444,8 @@ Enjoy!! :D frame.irisStageIndex++; if (frame.irisStageIndex >= frame.irisStages.length) return; - frame.irisWaitUntil = Date.now() + stage.waitAfter * 1000; - util.yieldTick(); + frame.irisWaitUntil = Date.now() + stage.waitAfter * 1000; + util.yieldTick(); } addLine(args, util) { @@ -2437,96 +2460,96 @@ Enjoy!! :D state.finalText = stripMarkup(state.rawText); state.visible = true; schedulePaint(util.target, state); - } - - _typeText(args, util, appendLine) { - const state = getState(util.target); - const frame = util.stackFrame; - - if (frame.irisTypingSteps === undefined) { - const text = Scratch.Cast.toString(args.TEXT); - const existingText = appendLine && state.visible ? state.rawText : ''; - const linePrefix = appendLine && existingText ? '\n' : ''; - state.rawText = existingText + linePrefix; - state.finalText = stripMarkup(state.rawText + text); - state.visible = true; - if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); - state.revealToken++; - state.typingControl = null; - frame.irisTypingSteps = splitTypingSteps(text); - frame.irisTypingStepIndex = 0; - frame.irisTypingCharIndex = stripMarkup(existingText + linePrefix).length; - frame.irisTypedText = state.rawText; - frame.irisTypingToken = state.revealToken; - frame.irisTypingWaitUntil = null; - } - - if (state.revealToken !== frame.irisTypingToken) return; - - if (state.typingControl === 'stop') { - state.typingControl = null; - state.finalText = stripMarkup(state.rawText); - return; - } - - if (state.typingControl === 'skip') { - while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { - frame.irisTypedText += frame.irisTypingSteps[frame.irisTypingStepIndex++].content; - } - state.rawText = frame.irisTypedText; - state.typingControl = null; - schedulePaint(util.target, state); - flushRenderIfDirty(util.target); - return; - } - - if (frame.irisTypingWaitUntil !== null) { - if (Date.now() < frame.irisTypingWaitUntil) { - util.yieldTick(); - return; - } - frame.irisTypingWaitUntil = null; - } - - while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { - const step = frame.irisTypingSteps[frame.irisTypingStepIndex++]; - frame.irisTypedText += step.content; - if (step.char === null) continue; - - state.rawText = frame.irisTypedText; - schedulePaint(util.target, state); - flushRenderIfDirty(util.target); - startTypingCharacterHat(util.target, step.char, frame.irisTypingCharIndex++); - - const delay = Math.max(0, Number(typingDelayForCharacter(state, step.char)) || 0); - if (delay > 0) { - frame.irisTypingWaitUntil = Date.now() + delay * 1000; - util.yieldTick(); - return; - } - } - - state.rawText = frame.irisTypedText; - schedulePaint(util.target, state); - } - - typeText(args, util) { - this._typeText(args, util, false); - } - - typeNewLine(args, util) { - this._typeText(args, util, true); - } - - skipTyping(args, util) { - getState(util.target).typingControl = 'skip'; - } - - stopTyping(args, util) { - getState(util.target).typingControl = 'stop'; - } - - clearText(args, util) { + } + + _typeText(args, util, appendLine) { + const state = getState(util.target); + const frame = util.stackFrame; + + if (frame.irisTypingSteps === undefined) { + const text = Scratch.Cast.toString(args.TEXT); + const existingText = appendLine && state.visible ? state.rawText : ''; + const linePrefix = appendLine && existingText ? '\n' : ''; + state.rawText = existingText + linePrefix; + state.finalText = stripMarkup(state.rawText + text); + state.visible = true; + if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); + state.revealToken++; + state.typingControl = null; + frame.irisTypingSteps = splitTypingSteps(text); + frame.irisTypingStepIndex = 0; + frame.irisTypingCharIndex = stripMarkup(existingText + linePrefix).length; + frame.irisTypedText = state.rawText; + frame.irisTypingToken = state.revealToken; + frame.irisTypingWaitUntil = null; + } + + if (state.revealToken !== frame.irisTypingToken) return; + + if (state.typingControl === 'stop') { + state.typingControl = null; + state.finalText = stripMarkup(state.rawText); + return; + } + + if (state.typingControl === 'skip') { + while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { + frame.irisTypedText += frame.irisTypingSteps[frame.irisTypingStepIndex++].content; + } + state.rawText = frame.irisTypedText; + state.typingControl = null; + schedulePaint(util.target, state); + flushRenderIfDirty(util.target); + return; + } + + if (frame.irisTypingWaitUntil !== null) { + if (Date.now() < frame.irisTypingWaitUntil) { + util.yieldTick(); + return; + } + frame.irisTypingWaitUntil = null; + } + + while (frame.irisTypingStepIndex < frame.irisTypingSteps.length) { + const step = frame.irisTypingSteps[frame.irisTypingStepIndex++]; + frame.irisTypedText += step.content; + if (step.char === null) continue; + + state.rawText = frame.irisTypedText; + schedulePaint(util.target, state); + flushRenderIfDirty(util.target); + startTypingCharacterHat(util.target, step.char, frame.irisTypingCharIndex++); + + const delay = Math.max(0, Number(typingDelayForCharacter(state, step.char)) || 0); + if (delay > 0) { + frame.irisTypingWaitUntil = Date.now() + delay * 1000; + util.yieldTick(); + return; + } + } + + state.rawText = frame.irisTypedText; + schedulePaint(util.target, state); + } + + typeText(args, util) { + this._typeText(args, util, false); + } + + typeNewLine(args, util) { + this._typeText(args, util, true); + } + + skipTyping(args, util) { + getState(util.target).typingControl = 'skip'; + } + + stopTyping(args, util) { + getState(util.target).typingControl = 'stop'; + } + + clearText(args, util) { clearTarget(util.target); } @@ -2536,7 +2559,7 @@ Enjoy!! :D scheduleTextRender(util.target); } - setBaseColor(args, util) { + setBaseColor(args, util) { const state = getState(util.target); const color = Scratch.Cast.toString(args.COLOR); if (state.baseStyle.color === color) return; @@ -2563,81 +2586,81 @@ Enjoy!! :D schedulePaint(util.target, state); } - getFontMenuItems() { - return buildFontMenuItems(); - } - - getSpriteMenuItems() { - const items = [{ - text: 'myself', - value: '_myself_' - }]; - const names = new Set(); - for (const target of runtime.targets) { - if (target.isStage || target.isOriginal === false) continue; - const name = target.getName(); - if (!names.has(name)) { - names.add(name); - items.push({ - text: name, - value: name - }); - } - } - return items; - } - - exportSettings(args, util) { - return exportTextSettings(getState(util.target)); - } - - applyExportSettings(args, util) { - let settings; - try { - settings = JSON.parse(Scratch.Cast.toString(args.SETTINGS)); - } catch (e) { - return; - } - const sprite = Scratch.Cast.toString(args.SPRITE); - const target = sprite === '_myself_' ? - util.target : - runtime.getSpriteTargetByName(sprite); - if (!target || !applyTextSettings(getState(target), settings)) return; - schedulePaint(target, getState(target)); - } - - setAlign(args, util) { - const state = getState(util.target); - const align = Scratch.Cast.toString(args.ALIGN); - if (state.align === align) return; - state.align = align; - schedulePaint(util.target, state); - } - - setSmoothing(args, util) { - const state = getState(util.target); - const smoothing = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - if (state.smoothing === smoothing) return; - state.smoothing = smoothing; - schedulePaint(util.target, state); - } - - setTypingSpeed(args, util) { - const state = getState(util.target); - const group = Scratch.Cast.toString(args.GROUP); - if (!_hasOwn.call(state.typingSpeeds, group)) return; - state.typingSpeeds[group] = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); - } - - setCustomTypingSpeed(args, util) { - const state = getState(util.target); - const delay = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); - for (const char of Array.from(Scratch.Cast.toString(args.CHARS))) { - state.customTypingSpeeds[char] = delay; - } - } - - setMaxWidth(args, util) { + getFontMenuItems() { + return buildFontMenuItems(); + } + + getSpriteMenuItems() { + const items = [{ + text: 'myself', + value: '_myself_' + }]; + const names = new Set(); + for (const target of runtime.targets) { + if (target.isStage || target.isOriginal === false) continue; + const name = target.getName(); + if (!names.has(name)) { + names.add(name); + items.push({ + text: name, + value: name + }); + } + } + return items; + } + + exportSettings(args, util) { + return exportTextSettings(getState(util.target)); + } + + applyExportSettings(args, util) { + let settings; + try { + settings = JSON.parse(Scratch.Cast.toString(args.SETTINGS)); + } catch (e) { + return; + } + const sprite = Scratch.Cast.toString(args.SPRITE); + const target = sprite === '_myself_' ? + util.target : + runtime.getSpriteTargetByName(sprite); + if (!target || !applyTextSettings(getState(target), settings)) return; + schedulePaint(target, getState(target)); + } + + setAlign(args, util) { + const state = getState(util.target); + const align = Scratch.Cast.toString(args.ALIGN); + if (state.align === align) return; + state.align = align; + schedulePaint(util.target, state); + } + + setSmoothing(args, util) { + const state = getState(util.target); + const smoothing = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + if (state.smoothing === smoothing) return; + state.smoothing = smoothing; + schedulePaint(util.target, state); + } + + setTypingSpeed(args, util) { + const state = getState(util.target); + const group = Scratch.Cast.toString(args.GROUP); + if (!_hasOwn.call(state.typingSpeeds, group)) return; + state.typingSpeeds[group] = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); + } + + setCustomTypingSpeed(args, util) { + const state = getState(util.target); + const delay = Math.max(0, Scratch.Cast.toNumber(args.SECONDS)); + for (const char of Array.from(Scratch.Cast.toString(args.CHARS))) { + state.customTypingSpeeds[char] = delay; + } + } + + setMaxWidth(args, util) { const state = getState(util.target); const width = Scratch.Cast.toNumber(args.WIDTH); if (state.maxWidth === width) return; @@ -2671,146 +2694,146 @@ Enjoy!! :D schedulePaint(util.target, state); } - setLetterSpacing(args, util) { + setLetterSpacing(args, util) { const state = getState(util.target); const letterSpacing = Scratch.Cast.toNumber(args.SPACE); if (state.letterSpacing === letterSpacing) return; state.letterSpacing = letterSpacing; - schedulePaint(util.target, state); - } - - setTextShadowEnabled(args, util) { - const state = getState(util.target); - state.textShadow.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - schedulePaint(util.target, state); - } - - setTextShadowColor(args, util) { - const state = getState(util.target); - const color = Scratch.Cast.toString(args.COLOR); - if (state.textShadow.color === color) return; - state.textShadow.color = color; - schedulePaint(util.target, state); - } - - setTextShadowOpacity(args, util) { - const state = getState(util.target); - state.textShadow.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); - schedulePaint(util.target, state); - } - - setTextShadowBlur(args, util) { - const state = getState(util.target); - state.textShadow.blur = Math.max(0, Scratch.Cast.toNumber(args.BLUR)); - schedulePaint(util.target, state); - } - - setTextShadowOffset(args, util) { - const state = getState(util.target); - state.textShadow.offsetX = Scratch.Cast.toNumber(args.X); - state.textShadow.offsetY = Scratch.Cast.toNumber(args.Y); - schedulePaint(util.target, state); - } - - setTextBackgroundEnabled(args, util) { - const state = getState(util.target); - state.textBackground.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - schedulePaint(util.target, state); - } - - setTextBackgroundColor(args, util) { - const state = getState(util.target); - const color = Scratch.Cast.toString(args.COLOR); - if (state.textBackground.color === color) return; - state.textBackground.color = color; - schedulePaint(util.target, state); - } - - setTextBackgroundOpacity(args, util) { - const state = getState(util.target); - state.textBackground.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); - schedulePaint(util.target, state); - } - - setTextBackgroundRadius(args, util) { - const state = getState(util.target); - state.textBackground.radius = Math.max(0, Scratch.Cast.toNumber(args.RADIUS)); - schedulePaint(util.target, state); - } - - setTextBackgroundPadding(args, util) { - const state = getState(util.target); - state.textBackground.padding = Math.max(0, Scratch.Cast.toNumber(args.PADDING)); - schedulePaint(util.target, state); - } - - setTextBorderEnabled(args, util) { - const state = getState(util.target); - state.textBorder.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - schedulePaint(util.target, state); - } - - setTextBorderColor(args, util) { - const state = getState(util.target); - const color = Scratch.Cast.toString(args.COLOR); - if (state.textBorder.color === color) return; - state.textBorder.color = color; - schedulePaint(util.target, state); - } - - setTextBorderOpacity(args, util) { - const state = getState(util.target); - state.textBorder.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); - schedulePaint(util.target, state); - } - - setTextBorderSize(args, util) { - const state = getState(util.target); - state.textBorder.size = Math.max(0, Scratch.Cast.toNumber(args.SIZE)); - schedulePaint(util.target, state); - } - - repeatForTag(args, util) { - const frame = util.stackFrame; - const rootFrame = util.thread.stackFrames[0]; - let loop = frame.irisTagLoop; - - if (!loop) { - const tag = Scratch.Cast.toString(args.TAG).toLowerCase(); - loop = { - indices: taggedCharacterIndices(util.target, tag), - position: 0 - }; - frame.irisTagLoop = loop; - rootFrame.irisTagCharacterNumber = 0; - } - - if (loop.position >= loop.indices.length) return; - rootFrame.irisTagCharacterNumber = loop.indices[loop.position] + 1; - loop.position++; - util.startBranch(1, true); - } - - currentCharValue(args, util) { - const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; - return cur ? cur.char : ''; - } - - currentCharIndex(args, util) { - const rootFrame = util.thread && util.thread.stackFrames[0]; - if (rootFrame && rootFrame.irisTagCharacterNumber !== undefined) { - return rootFrame.irisTagCharacterNumber; - } - const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; - return cur ? cur.index + 1 : 0; - } - - setResetCharTransformsOnText(args, util) { - const state = getState(util.target); - state.resetCharTransformsOnText = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; - } - - setCharPos(args, util) { + schedulePaint(util.target, state); + } + + setTextShadowEnabled(args, util) { + const state = getState(util.target); + state.textShadow.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextShadowColor(args, util) { + const state = getState(util.target); + const color = Scratch.Cast.toString(args.COLOR); + if (state.textShadow.color === color) return; + state.textShadow.color = color; + schedulePaint(util.target, state); + } + + setTextShadowOpacity(args, util) { + const state = getState(util.target); + state.textShadow.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextShadowBlur(args, util) { + const state = getState(util.target); + state.textShadow.blur = Math.max(0, Scratch.Cast.toNumber(args.BLUR)); + schedulePaint(util.target, state); + } + + setTextShadowOffset(args, util) { + const state = getState(util.target); + state.textShadow.offsetX = Scratch.Cast.toNumber(args.X); + state.textShadow.offsetY = Scratch.Cast.toNumber(args.Y); + schedulePaint(util.target, state); + } + + setTextBackgroundEnabled(args, util) { + const state = getState(util.target); + state.textBackground.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextBackgroundColor(args, util) { + const state = getState(util.target); + const color = Scratch.Cast.toString(args.COLOR); + if (state.textBackground.color === color) return; + state.textBackground.color = color; + schedulePaint(util.target, state); + } + + setTextBackgroundOpacity(args, util) { + const state = getState(util.target); + state.textBackground.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextBackgroundRadius(args, util) { + const state = getState(util.target); + state.textBackground.radius = Math.max(0, Scratch.Cast.toNumber(args.RADIUS)); + schedulePaint(util.target, state); + } + + setTextBackgroundPadding(args, util) { + const state = getState(util.target); + state.textBackground.padding = Math.max(0, Scratch.Cast.toNumber(args.PADDING)); + schedulePaint(util.target, state); + } + + setTextBorderEnabled(args, util) { + const state = getState(util.target); + state.textBorder.enabled = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + schedulePaint(util.target, state); + } + + setTextBorderColor(args, util) { + const state = getState(util.target); + const color = Scratch.Cast.toString(args.COLOR); + if (state.textBorder.color === color) return; + state.textBorder.color = color; + schedulePaint(util.target, state); + } + + setTextBorderOpacity(args, util) { + const state = getState(util.target); + state.textBorder.opacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.PCT))); + schedulePaint(util.target, state); + } + + setTextBorderSize(args, util) { + const state = getState(util.target); + state.textBorder.size = Math.max(0, Scratch.Cast.toNumber(args.SIZE)); + schedulePaint(util.target, state); + } + + repeatForTag(args, util) { + const frame = util.stackFrame; + const rootFrame = util.thread.stackFrames[0]; + let loop = frame.irisTagLoop; + + if (!loop) { + const tag = Scratch.Cast.toString(args.TAG).toLowerCase(); + loop = { + indices: taggedCharacterIndices(util.target, tag), + position: 0 + }; + frame.irisTagLoop = loop; + rootFrame.irisTagCharacterNumber = 0; + } + + if (loop.position >= loop.indices.length) return; + rootFrame.irisTagCharacterNumber = loop.indices[loop.position] + 1; + loop.position++; + util.startBranch(1, true); + } + + currentCharValue(args, util) { + const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; + return cur ? cur.char : ''; + } + + currentCharIndex(args, util) { + const rootFrame = util.thread && util.thread.stackFrames[0]; + if (rootFrame && rootFrame.irisTagCharacterNumber !== undefined) { + return rootFrame.irisTagCharacterNumber; + } + const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; + return cur ? cur.index + 1 : 0; + } + + setResetCharTransformsOnText(args, util) { + const state = getState(util.target); + state.resetCharTransformsOnText = Scratch.Cast.toString(args.ENABLED).toLowerCase() === 'on'; + } + + setCharPos(args, util) { const state = getState(util.target); const idx = Scratch.Cast.toNumber(args.INDEX) - 1; const x = Scratch.Cast.toNumber(args.X); @@ -2836,6 +2859,18 @@ Enjoy!! :D schedulePaint(util.target, state); } + setCharAxisPos(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const axis = Scratch.Cast.toString(args.AXIS) === 'y' ? 'y' : 'x'; + const value = Scratch.Cast.toNumber(args.VALUE); + const exists = !!state.charOverrides[idx]; + const o = getCharOverride(state, idx); + if (exists && o[axis] === value) return; + o[axis] = value; + schedulePaint(util.target, state); + } + setCharRotation(args, util) { const state = getState(util.target); const idx = Scratch.Cast.toNumber(args.INDEX) - 1; @@ -2879,59 +2914,59 @@ Enjoy!! :D } } - resetAllCharTransforms(args, util) { - const state = getState(util.target); - if (Object.keys(state.charOverrides).length) { + resetAllCharTransforms(args, util) { + const state = getState(util.target); + if (Object.keys(state.charOverrides).length) { state.charOverrides = {}; state.charOverridesVersion++; schedulePaint(util.target, state); - } - } - - getCharacterStyle(args, util) { - const state = getState(util.target); - flushRenderIfDirty(util.target); - const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - const box = state.charBoxes[idx]; - return box && box.style ? JSON.stringify(box.style) : ''; - } - - setCharacterStyle(args, util) { - const state = getState(util.target); - const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - flushRenderIfDirty(util.target); - const box = state.charBoxes[idx]; - if (!box || !box.style) return; - - let input; - try { - input = JSON.parse(Scratch.Cast.toString(args.STYLE)); - } catch (e) { - return; - } - const style = characterStyleFromJSON(input, box.style); - if (!style) return; - state.charStyleOverrides[idx] = style; - state.charStyleOverridesVersion++; - state.shapeKey = null; - state.layout = null; - state.paintFingerprint = null; - schedulePaint(util.target, state); - } - - resetCharacterStyle(args, util) { - const state = getState(util.target); - const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - if (!state.charStyleOverrides[idx]) return; - delete state.charStyleOverrides[idx]; - state.charStyleOverridesVersion++; - state.shapeKey = null; - state.layout = null; - state.paintFingerprint = null; - schedulePaint(util.target, state); - } - - getCharX(args, util) { + } + } + + getCharacterStyle(args, util) { + const state = getState(util.target); + flushRenderIfDirty(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const box = state.charBoxes[idx]; + return box && box.style ? JSON.stringify(box.style) : ''; + } + + setCharacterStyle(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + flushRenderIfDirty(util.target); + const box = state.charBoxes[idx]; + if (!box || !box.style) return; + + let input; + try { + input = JSON.parse(Scratch.Cast.toString(args.STYLE)); + } catch (e) { + return; + } + const style = characterStyleFromJSON(input, box.style); + if (!style) return; + state.charStyleOverrides[idx] = style; + state.charStyleOverridesVersion++; + state.shapeKey = null; + state.layout = null; + state.paintFingerprint = null; + schedulePaint(util.target, state); + } + + resetCharacterStyle(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + if (!state.charStyleOverrides[idx]) return; + delete state.charStyleOverrides[idx]; + state.charStyleOverridesVersion++; + state.shapeKey = null; + state.layout = null; + state.paintFingerprint = null; + schedulePaint(util.target, state); + } + + getCharX(args, util) { const state = getState(util.target); flushRenderIfDirty(util.target); const idx = Scratch.Cast.toNumber(args.INDEX) - 1; @@ -3014,4 +3049,4 @@ Enjoy!! :D } Scratch.extensions.register(new IrisText()); -})(Scratch); +})(Scratch); From 0364620baf46dbbbb9efb850deba37f58fff359f Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Sat, 22 Aug 2026 16:21:52 -0300 Subject: [PATCH 10/21] Big Boy update --- static/extensions/Gen1x/iris-text.js | 1943 +++++++++++++++++++++++--- 1 file changed, 1726 insertions(+), 217 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 1b844e181..7ae6d2243 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -2,9 +2,8 @@ Iris Text by Gen1x - 2026 Code inspiration: -- Skins (TurboWarp version) - Animated Text (PenguinMod version) -- Lambda by jwklong +- Tween Extension by JeremyGamer13 Enjoy!! :D */ @@ -28,6 +27,11 @@ Enjoy!! :D const COLOR_SECONDARY = '#149DA9'; const COLOR_TERTIARY = '#0F808A'; + const WIPE_DIRECTION_BOTTOM_UP = 'bottom-up'; + const WIPE_DIRECTION_LEFT_RIGHT = 'left-right'; + const WIPE_DIRECTION_UP_DOWN = 'up-down'; + const WIPE_DIRECTION_RIGHT_LEFT = 'right-left'; + /* stolen from PM's animated text extension code :3 */ const SANS_SERIF_ID = 'Sans Serif'; const SERIF_ID = 'Serif'; @@ -261,6 +265,25 @@ Enjoy!! :D }; } + function styleSnapshotMatches(snapshot, style) { + if (!snapshot) return false; + if (snapshot.color !== style.color) return false; + if (snapshot.bold !== style.bold) return false; + if (snapshot.italic !== style.italic) return false; + if (snapshot.underline !== style.underline) return false; + if (snapshot.strike !== style.strike) return false; + if (snapshot.size !== style.size) return false; + if (snapshot.font !== style.font) return false; + const tags = Array.isArray(style.tags) ? style.tags : null; + const snapTags = snapshot.tags; + if (!tags) return snapTags.length === 0; + if (tags.length !== snapTags.length) return false; + for (let i = 0; i < tags.length; i++) { + if (tags[i] !== snapTags[i]) return false; + } + return true; + } + function applyCharacterStyleOverrides(chars, overrides) { for (const index in overrides) { const char = chars[index]; @@ -379,6 +402,9 @@ Enjoy!! :D resetCharTransformsOnText: true, charStyleOverrides: {}, charStyleOverridesVersion: 0, + charMasks: {}, + charMasksVersion: 0, + charMaskGeometryVersion: 0, typingControl: null, skinId: null, drawableWidth: 0, @@ -389,11 +415,15 @@ Enjoy!! :D layoutKey: null, layout: null, charBoxesLayout: null, + paintOps: null, + paintOpsKey: null, + paintOpsLayout: null, paintDirty: true, paintFingerprint: null, lastFamiliesKey: null, lastFontDefs: '', - revealToken: 0 + revealToken: 0, + fontsPendingAtMeasure: false }; } @@ -421,7 +451,7 @@ Enjoy!! :D if (!state) { state = defaultState(); target.setCustomState(STATE_KEY, state); - } else { + } else if (!state.iiMigrated) { const defaults = defaultState(); state.textShadow = Object.assign(defaults.textShadow, state.textShadow || {}); state.textBackground = Object.assign(defaults.textBackground, state.textBackground || {}); @@ -433,6 +463,7 @@ Enjoy!! :D if (typeof state.resetCharTransformsOnText !== 'boolean') state.resetCharTransformsOnText = defaults.resetCharTransformsOnText; if (typeof state.charStyleOverridesVersion !== 'number') state.charStyleOverridesVersion = 0; if (state.typingControl !== 'skip' && state.typingControl !== 'stop') state.typingControl = null; + state.iiMigrated = true; } return state; } @@ -574,7 +605,9 @@ Enjoy!! :D x: 0, y: 0, rotation: 0, - opacity: 1 + opacity: 1, + scale: 1, + color: null }; state.charOverridesVersion++; } @@ -587,19 +620,32 @@ Enjoy!! :D state.charOverridesVersion++; } - const SVG_NS = 'http://www.w3.org/2000/svg'; - let measureSvg = null; + function setCharMaskForIndex(state, index, mask) { + state.charMasks[index] = mask; + state.charMasksVersion++; + state.charMaskGeometryVersion++; + } + + function clearCharMaskForIndex(state, index) { + if (!state.charMasks[index]) return false; + delete state.charMasks[index]; + state.charMasksVersion++; + state.charMaskGeometryVersion++; + return true; + } - function getMeasureSvg() { - if (measureSvg && document.body.contains(measureSvg)) return measureSvg; - measureSvg = document.createElementNS(SVG_NS, 'svg'); - measureSvg.setAttribute('width', '0'); - measureSvg.setAttribute('height', '0'); - measureSvg.style.position = 'absolute'; - measureSvg.style.visibility = 'hidden'; - measureSvg.style.pointerEvents = 'none'; - document.body.appendChild(measureSvg); - return measureSvg; + function indexRange(startArg, endArg) { + let start = Math.round(startArg) - 1; + let end = Math.round(endArg) - 1; + if (end < start) { + const tmp = start; + start = end; + end = tmp; + } + return { + start, + end + }; } const GLYPH_OVERSAMPLE = 3; @@ -684,6 +730,177 @@ Enjoy!! :D return entry; } + const maskPatternSurfaces = new Map(); + const maskGlyphStencilCache = new Map(); + const STENCIL_CACHE_LIMIT = 400; + const maskScratchCanvasPool = []; + + function getMaskPatternSurface(texture) { + let surface = maskPatternSurfaces.get(texture.cacheKey); + if (surface) return surface; + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d'); + surface = { canvas, ctx, pattern: null, patternDirty: true }; + maskPatternSurfaces.set(texture.cacheKey, surface); + return surface; + } + + function sampleMaskPattern(texture, matrix, w, h) { + const surface = getMaskPatternSurface(texture); + if (surface.canvas.width !== w || surface.canvas.height !== h) { + surface.canvas.width = w; + surface.canvas.height = h; + surface.patternDirty = true; + } + if (surface.patternDirty) { + surface.pattern = surface.ctx.createPattern(texture.canvas, 'repeat'); + surface.patternDirty = false; + } + if (!surface.pattern) return null; + + surface.pattern.setTransform(matrix); + surface.ctx.setTransform(1, 0, 0, 1, 0, 0); + surface.ctx.globalAlpha = 1; + surface.ctx.globalCompositeOperation = 'source-over'; + surface.ctx.clearRect(0, 0, w, h); + surface.ctx.fillStyle = surface.pattern; + surface.ctx.fillRect(0, 0, w, h); + + return surface.canvas; + } + + function getMaskGlyphStencil(shape) { + let stencil = maskGlyphStencilCache.get(shape); + if (stencil) return stencil; + stencil = document.createElement('canvas'); + stencil.width = shape.canvas.width; + stencil.height = shape.canvas.height; + const sctx = stencil.getContext('2d'); + sctx.fillStyle = '#ffffff'; + sctx.fillRect(0, 0, stencil.width, stencil.height); + sctx.globalCompositeOperation = 'destination-in'; + sctx.drawImage(shape.canvas, 0, 0); + if (maskGlyphStencilCache.size >= STENCIL_CACHE_LIMIT) { + maskGlyphStencilCache.delete(maskGlyphStencilCache.keys().next().value); + } + maskGlyphStencilCache.set(shape, stencil); + return stencil; + } + + function acquireMaskScratchCanvas(w, h) { + const reused = maskScratchCanvasPool.pop(); + const canvas = reused || document.createElement('canvas'); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + return canvas; + } + + function releaseMaskScratchCanvas(canvas) { + if (maskScratchCanvasPool.length < 64) maskScratchCanvasPool.push(canvas); + } + + function drawMaskedGlyph(destCtx, char, fontFamily, size, weight, style, mask, texture, worldDrawX, worldDrawY, spanW, spanH, spanOriginX, spanOriginY, destX, destY, alpha) { + const coverage = Math.max(0, Math.min(100, Number(mask.coverage) || 0)) / 100; + if (coverage <= 0) return; + + const shape = getGlyphShape(char, fontFamily, size, weight, style); + const w = shape.canvas.width; + const h = shape.canvas.height; + + const zoom = Math.max(0.01, (Number(mask.zoom) || 100) / 100); + const rotation = Number(mask.rotation) || 0; + const anchorX = (mask.x || 0) * DEST_SCALE - destX; + const anchorY = -(mask.y || 0) * DEST_SCALE - destY; + + const matrix = new DOMMatrix(); + matrix.translateSelf(anchorX, anchorY); + if (rotation !== 0) matrix.rotateSelf(rotation); + matrix.scaleSelf(zoom, zoom); + + const sampled = sampleMaskPattern(texture, matrix, w, h); + if (!sampled) return; + + const scratch = acquireMaskScratchCanvas(w, h); + const sctx = scratch.getContext('2d'); + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.clearRect(0, 0, w, h); + sctx.globalAlpha = 1; + sctx.globalCompositeOperation = 'source-over'; + sctx.drawImage(sampled, 0, 0); + + const stencil = getMaskGlyphStencil(shape); + sctx.globalCompositeOperation = 'destination-in'; + sctx.drawImage(stencil, 0, 0); + sctx.globalCompositeOperation = 'source-over'; + + if (coverage < 1) { + const seamless = !!mask.seamless; + const localOffsetX = seamless ? (worldDrawX - spanOriginX) : 0; + const localOffsetY = seamless ? (worldDrawY - spanOriginY) : 0; + const effSpanW = seamless && spanW ? spanW : w; + const effSpanH = seamless && spanH ? spanH : h; + const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; + const direction = mask.direction || WIPE_DIRECTION_BOTTOM_UP; + const bandHalf = Math.max(0.5, blurPx / 2); + + let revealEdge, axisIsX, growsPositive; + if (direction === WIPE_DIRECTION_LEFT_RIGHT) { + revealEdge = effSpanW * coverage - localOffsetX; + axisIsX = true; + growsPositive = true; + } else if (direction === WIPE_DIRECTION_RIGHT_LEFT) { + revealEdge = effSpanW - effSpanW * coverage - localOffsetX; + axisIsX = true; + growsPositive = false; + } else if (direction === WIPE_DIRECTION_UP_DOWN) { + revealEdge = effSpanH * coverage - localOffsetY; + axisIsX = false; + growsPositive = true; + } else { + revealEdge = effSpanH - effSpanH * coverage - localOffsetY; + axisIsX = false; + growsPositive = false; + } + + const axisSize = axisIsX ? w : h; + const gradStart = growsPositive ? revealEdge - bandHalf : revealEdge + bandHalf; + const gradEnd = growsPositive ? revealEdge + bandHalf : revealEdge - bandHalf; + const bandFrom = Math.min(gradStart, gradEnd); + const bandTo = Math.max(gradStart, gradEnd); + + if (bandTo <= 0) { + if (growsPositive) { + releaseMaskScratchCanvas(scratch); + return; + } + } else if (bandFrom >= axisSize) { + if (!growsPositive) { + releaseMaskScratchCanvas(scratch); + return; + } + } else { + sctx.globalCompositeOperation = 'destination-in'; + const gradient = axisIsX ? + sctx.createLinearGradient(gradStart, 0, gradEnd, 0) : + sctx.createLinearGradient(0, gradStart, 0, gradEnd); + gradient.addColorStop(0, 'rgba(255,255,255,1)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + sctx.fillStyle = gradient; + sctx.fillRect(0, 0, w, h); + sctx.globalCompositeOperation = 'source-over'; + } + } + + destCtx.save(); + destCtx.globalAlpha = alpha; + destCtx.drawImage(scratch, destX, destY); + destCtx.restore(); + + releaseMaskScratchCanvas(scratch); + } + function tintGlyph(shape, color) { let tintedCanvas = shape.tinted.get(color); if (tintedCanvas) return tintedCanvas; @@ -706,8 +923,10 @@ Enjoy!! :D function getGlyphBitmap(char, fontFamily, size, weight, style, color) { const shape = getGlyphShape(char, fontFamily, size, weight, style); + let bitmap = shape.bitmaps && shape.bitmaps.get(color); + if (bitmap) return bitmap; const canvas = tintGlyph(shape, color); - return { + bitmap = { canvas, advance: shape.advance, baselineOriginXEm: shape.baselineOriginXEm, @@ -715,6 +934,12 @@ Enjoy!! :D fontAscentEm: shape.fontAscentEm, fontDescentEm: shape.fontDescentEm }; + if (!shape.bitmaps) shape.bitmaps = new Map(); + if (shape.bitmaps.size >= TINT_CACHE_LIMIT) { + shape.bitmaps.delete(shape.bitmaps.keys().next().value); + } + shape.bitmaps.set(color, bitmap); + return bitmap; } function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { @@ -767,7 +992,199 @@ Enjoy!! :D } } - function svgFontStyle(style) { + const MASK_TEXTURE_RASTER_SIZE = 512; + const maskTextureCache = new Map(); + const maskTexturePending = new Map(); + + function maskCostumeKey(targetName, costume) { + return targetName + '\u0001' + costume.name + '\u0001' + (costume.assetId || costume.skinId || ''); + } + + function findCostumeByRef(targetName, costumeName) { + for (const target of runtime.targets) { + if (target.isOriginal === false) continue; + const name = target.isStage ? 'Stage' : target.getName(); + if (name !== targetName) continue; + const costumes = target.getCostumes(); + for (const costume of costumes) { + if (costume.name === costumeName) return costume; + } + } + return null; + } + + function rasterizeCostumeFromSkin(costume, targetName) { + if (!runtime.renderer || typeof costume.skinId !== 'number') return null; + const renderer = runtime.renderer; + const skin = renderer._allSkins && renderer._allSkins[costume.skinId]; + if (!skin) return null; + + let source = null; + if (skin._silhouette && skin._silhouette._lazyData) { + source = skin._silhouette._lazyData; + } else if (skin._svgImage && skin._svgImageLoaded) { + source = skin._svgImage; + } else if (skin._image) { + source = skin._image; + } else if (skin.canvas) { + source = skin.canvas; + } else if (skin._canvas) { + source = skin._canvas; + } else if (skin._svgRenderer && skin._svgRenderer._cachedImage) { + source = skin._svgRenderer._cachedImage; + } else if (skin._svgRenderer && skin._svgRenderer.canvas) { + source = skin._svgRenderer.canvas; + } + if (!source) return null; + + const silhouetteW = skin._silhouette && skin._silhouette._width; + const silhouetteH = skin._silhouette && skin._silhouette._height; + const nativeW = source.naturalWidth || source.width || silhouetteW || (skin.size && skin.size[0]) || 1; + const nativeH = source.naturalHeight || source.height || silhouetteH || (skin.size && skin.size[1]) || 1; + if (!nativeW || !nativeH) return null; + const aspect = nativeW / nativeH; + let canvasW, canvasH; + if (aspect >= 1) { + canvasW = MASK_TEXTURE_RASTER_SIZE; + canvasH = Math.max(1, Math.round(MASK_TEXTURE_RASTER_SIZE / aspect)); + } else { + canvasH = MASK_TEXTURE_RASTER_SIZE; + canvasW = Math.max(1, Math.round(MASK_TEXTURE_RASTER_SIZE * aspect)); + } + const canvas = document.createElement('canvas'); + canvas.width = canvasW; + canvas.height = canvasH; + const ctx = canvas.getContext('2d'); + try { + ctx.drawImage(source, 0, 0, canvasW, canvasH); + } catch (e) { + return null; + } + return { canvas, width: canvasW, height: canvasH }; + } + + function rasterizeCostumeToCanvas(costume, targetName) { + return new Promise((resolve) => { + const asset = costume.asset; + if (!asset) { + const fromSkin = rasterizeCostumeFromSkin(costume, targetName); + resolve(fromSkin); + return; + } + let url; + let revoke = false; + try { + if (costume.dataFormat === 'svg') { + const svgText = asset.decodeText(); + url = 'data:image/svg+xml;charset=utf8,' + encodeURIComponent(svgText); + } else { + url = asset.encodeDataURI(); + } + } catch (e) { + const fromSkin = rasterizeCostumeFromSkin(costume, targetName); + resolve(fromSkin); + return; + } + + const img = new Image(); + img.onload = () => { + const nativeW = img.naturalWidth || img.width || 1; + const nativeH = img.naturalHeight || img.height || 1; + const aspect = nativeW / nativeH; + let canvasW, canvasH; + if (aspect >= 1) { + canvasW = MASK_TEXTURE_RASTER_SIZE; + canvasH = Math.max(1, Math.round(MASK_TEXTURE_RASTER_SIZE / aspect)); + } else { + canvasH = MASK_TEXTURE_RASTER_SIZE; + canvasW = Math.max(1, Math.round(MASK_TEXTURE_RASTER_SIZE * aspect)); + } + const canvas = document.createElement('canvas'); + canvas.width = canvasW; + canvas.height = canvasH; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, canvasW, canvasH); + if (revoke) URL.revokeObjectURL(url); + resolve({ + canvas, + width: canvasW, + height: canvasH + }); + }; + img.onerror = () => { + if (revoke) URL.revokeObjectURL(url); + const fromSkin = rasterizeCostumeFromSkin(costume, targetName); + resolve(fromSkin); + }; + img.src = url; + }); + } + + const maskRefRetryCounts = new Map(); + const MASK_REF_MAX_RETRIES = 20; + const MASK_REF_RETRY_DELAY_MS = 150; + + function getMaskTexture(targetName, costumeName) { + const refKey = targetName + '\u0001' + costumeName; + const costume = findCostumeByRef(targetName, costumeName); + if (!costume) { + const attempts = maskRefRetryCounts.get(refKey) || 0; + if (attempts < MASK_REF_MAX_RETRIES) { + maskRefRetryCounts.set(refKey, attempts + 1); + setTimeout(() => { + getMaskTexture(targetName, costumeName); + }, MASK_REF_RETRY_DELAY_MS); + } + return null; + } + maskRefRetryCounts.delete(refKey); + const key = maskCostumeKey(targetName, costume); + const cached = maskTextureCache.get(key); + if (cached) return cached; + + if (!maskTexturePending.has(key)) { + const promise = rasterizeCostumeToCanvas(costume, targetName).then((result) => { + maskTexturePending.delete(key); + if (result) { + result.cacheKey = key; + maskTextureCache.set(key, result); + repaintAllTargetsUsingMasks(); + } else { + const attempts = maskRefRetryCounts.get(refKey) || 0; + if (attempts < MASK_REF_MAX_RETRIES) { + maskRefRetryCounts.set(refKey, attempts + 1); + setTimeout(() => { + getMaskTexture(targetName, costumeName); + }, MASK_REF_RETRY_DELAY_MS); + } else if (typeof console !== 'undefined' && console.warn) { + const skin = runtime.renderer && runtime.renderer._allSkins && runtime.renderer._allSkins[costume.skinId]; + console.warn('[iris-text] mask texture failed to load after retries:', targetName, costumeName, + 'costume.asset present:', !!costume.asset, + 'dataFormat:', costume.dataFormat, + 'skinId:', costume.skinId, + 'skin found:', !!skin, + 'silhouette lazyData present:', !!(skin && skin._silhouette && skin._silhouette._lazyData), + 'skin keys:', skin ? Object.keys(skin) : null); + } + } + return result; + }); + maskTexturePending.set(key, promise); + } + return null; + } + + function repaintAllTargetsUsingMasks() { + for (const candidate of runtime.targets) { + const state = candidate.getCustomState(STATE_KEY); + if (!state || !state.visible) continue; + if (!state.charMasks || !Object.keys(state.charMasks).length) continue; + state.paintFingerprint = null; + schedulePaint(candidate, state); + } + } + + function shapingFontStyle(style) { const fontFamily = cssFontFamily(style.font); const fontWeight = style.bold ? 'bold' : 'normal'; const fontStyle = style.italic ? 'italic' : 'normal'; @@ -781,10 +1198,8 @@ Enjoy!! :D }; } - function setAttrs(el, attrs) { - for (const key in attrs) { - el.setAttribute(key, attrs[key]); - } + function canvasFontString(fontStyle) { + return `${fontStyle['font-style']} ${fontStyle['font-weight']} ${fontStyle['font-size']}px ${fontStyle['font-family']}`; } function sameShapingStyle(a, b) { @@ -820,64 +1235,64 @@ Enjoy!! :D return runs; } - function measureShapedRuns(svg, runs, letterSpacing) { - const textEl = document.createElementNS(SVG_NS, 'text'); - const tspans = []; - for (const run of runs) { - const tspan = document.createElementNS(SVG_NS, 'tspan'); - run.fontStyle = svgFontStyle(run.style); - run.text = run.chars.map(rc => (rc.char === ' ' ? '\u00A0' : rc.char)).join(''); - setAttrs(tspan, run.fontStyle); - if (letterSpacing) tspan.setAttribute('letter-spacing', letterSpacing); - tspan.textContent = run.text; - textEl.appendChild(tspan); - tspans.push(tspan); - } - svg.appendChild(textEl); - - let globalOffset = 0; - for (let r = 0; r < runs.length; r++) { - const run = runs[r]; - const n = run.chars.length; - const positions = new Array(n); - const runOrigin = n > 0 ? - (safeStartX(textEl, globalOffset)) : - 0; - for (let i = 0; i < n; i++) { - const idx = globalOffset + i; - let shapedX = 0; - let advance; - try { - shapedX = textEl.getStartPositionOfChar(idx).x - runOrigin; - advance = textEl.getExtentOfChar(idx).width; - } catch (e) { - const before = i === 0 ? 0 : tspans[r].getSubStringLength(0, i); - const upTo = tspans[r].getSubStringLength(0, i + 1); - shapedX = before; - advance = upTo - before; + const charAdvanceCache = new Map(); + const CHAR_ADVANCE_CACHE_LIMIT = 4000; + + function charAdvanceCacheKey(fontStyle, letterSpacing, char) { + return fontStyle.styleHash + '\u0001' + fontStyle['font-size'] + '\u0001' + + (letterSpacing || 0) + '\u0001' + char; + } + + function measureCharsIndividually(measureCtx, run, letterSpacing) { + const missing = []; + for (let i = 0; i < run.chars.length; i++) { + const ch = run.chars[i].char === ' ' ? '\u00A0' : run.chars[i].char; + const key = charAdvanceCacheKey(run.fontStyle, letterSpacing, ch); + if (!charAdvanceCache.has(key)) missing.push({ + i, + ch, + key + }); + } + + if (missing.length) { + measureCtx.font = canvasFontString(run.fontStyle); + measureCtx.textBaseline = 'alphabetic'; + const spacing = letterSpacing || 0; + for (let m = 0; m < missing.length; m++) { + const advance = measureCtx.measureText(missing[m].ch).width + spacing; + if (charAdvanceCache.size >= CHAR_ADVANCE_CACHE_LIMIT) { + charAdvanceCache.delete(charAdvanceCache.keys().next().value); } - positions[i] = { - shapedX, - advance - }; + charAdvanceCache.set(missing[m].key, advance); } - run.positions = positions; - run.runWidth = tspans[r].getComputedTextLength(); - globalOffset += n; } - svg.removeChild(textEl); + const positions = new Array(run.chars.length); + let x = 0; + for (let i = 0; i < run.chars.length; i++) { + const ch = run.chars[i].char === ' ' ? '\u00A0' : run.chars[i].char; + const key = charAdvanceCacheKey(run.fontStyle, letterSpacing, ch); + const advance = charAdvanceCache.get(key); + positions[i] = { + shapedX: x, + advance + }; + x += advance; + } + run.positions = positions; + run.runWidth = x; } - function safeStartX(textEl, idx) { - try { - return textEl.getStartPositionOfChar(idx).x; - } catch (e) { - return 0; + function measureShapedRuns(measureCtx, runs, letterSpacing) { + for (const run of runs) { + run.fontStyle = shapingFontStyle(run.style); + run.text = run.chars.map(rc => (rc.char === ' ' ? '\u00A0' : rc.char)).join(''); + measureCharsIndividually(measureCtx, run, letterSpacing); } } - function measureRichText(svg, richChars, state) { + function measureRichText(measureCtx, richChars, state) { const hardLines = [ [] ]; @@ -892,7 +1307,7 @@ Enjoy!! :D let globalIndex = 0; for (const line of hardLines) { const runs = splitShapingRuns(line, state.charOverrides, globalIndex); - if (runs.length) measureShapedRuns(svg, runs, state.letterSpacing); + if (runs.length) measureShapedRuns(measureCtx, runs, state.letterSpacing); for (const run of runs) { for (let i = 0; i < run.chars.length; i++) { run.chars[i]._width = run.positions[i].advance; @@ -970,7 +1385,7 @@ Enjoy!! :D let runStartX = 0; for (const run of runs) { - run.fontStyle = svgFontStyle(run.style); + run.fontStyle = shapingFontStyle(run.style); run.runStartX = runStartX; run.positions = []; let charX = 0; @@ -1017,12 +1432,27 @@ Enjoy!! :D hash = (hash * 33) ^ ((op.y * 100) | 0); hash = (hash * 33) ^ ((op.rotation * 100) | 0); hash = (hash * 33) ^ ((op.opacity * 1000) | 0); + hash = (hash * 33) ^ ((op.scale * 1000) | 0); hash = (hash * 33) ^ hashString(op.color); hash = (hash * 33) ^ op.font.styleHash; hash = (hash * 33) ^ ((op.font['font-size'] * 100) | 0); hash = (hash * 33) ^ ((op.letterSpacing * 100) | 0); hash = (hash * 33) ^ (op.underline ? 1 : 0); hash = (hash * 33) ^ (op.strike ? 1 : 0); + if (op.mask) { + const m = op.mask; + hash = (hash * 33) ^ hashString(m.targetName); + hash = (hash * 33) ^ hashString(m.costumeName); + hash = (hash * 33) ^ ((m.coverage * 100) | 0); + hash = (hash * 33) ^ ((m.opacity != null ? m.opacity : 100) * 100 | 0); + hash = (hash * 33) ^ ((m.blur * 100) | 0); + hash = (hash * 33) ^ ((m.x * 100) | 0); + hash = (hash * 33) ^ ((m.y * 100) | 0); + hash = (hash * 33) ^ ((m.zoom != null ? m.zoom : 100) * 100 | 0); + hash = (hash * 33) ^ ((m.rotation != null ? m.rotation : 0) * 100 | 0); + } else { + hash = (hash * 33) ^ 0x5a5a; + } } return hash | 0; } @@ -1037,12 +1467,180 @@ Enjoy!! :D } const pendingRenderTargets = new Set(); - let renderFlushScheduled = false; + + const charAnimations = new Map(); + let animationTickerInstalled = false; + + function nowMs() { + return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + } + + const linear = x => x; + const sine = (x, dir) => { + switch (dir) { + case "in": return 1 - Math.cos((x * Math.PI) / 2); + case "out": return Math.sin((x * Math.PI) / 2); + case "in out": return -(Math.cos(Math.PI * x) - 1) / 2; + default: return 0; + } + }; + const quad = (x, dir) => { + switch (dir) { + case "in": return x * x; + case "out": return 1 - (1 - x) * (1 - x); + case "in out": return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; + default: return 0; + } + }; + const cubic = (x, dir) => { + switch (dir) { + case "in": return x * x * x; + case "out": return 1 - Math.pow(1 - x, 3); + case "in out": return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; + default: return 0; + } + }; + const quart = (x, dir) => { + switch (dir) { + case "in": return x * x * x * x; + case "out": return 1 - Math.pow(1 - x, 4); + case "in out": return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2; + default: return 0; + } + }; + const quint = (x, dir) => { + switch (dir) { + case "in": return x * x * x * x * x; + case "out": return 1 - Math.pow(1 - x, 5); + case "in out": return x < 0.5 ? 16 * x * x * x * x * x : 1 - Math.pow(-2 * x + 2, 5) / 2; + default: + return 0; + } + }; + const expo = (x, dir) => { + switch (dir) { + case "in": return x === 0 ? 0 : Math.pow(2, 10 * x - 10); + case "out": return x === 1 ? 1 : 1 - Math.pow(2, -10 * x); + case "in out": return x === 0 ? 0 : x === 1 ? 1 : x < 0.5 + ? Math.pow(2, 20 * x - 10) / 2 : (2 - Math.pow(2, -20 * x + 10)) / 2; + default: return 0; + } + }; + const circ = (x, dir) => { + switch (dir) { + case "in": return 1 - Math.sqrt(1 - Math.pow(x, 2)); + case "out": return Math.sqrt(1 - Math.pow(x - 1, 2)); + case "in out": return x < 0.5 ? (1 - Math.sqrt(1 - Math.pow(2 * x, 2))) / 2 + : (Math.sqrt(1 - Math.pow(-2 * x + 2, 2)) + 1) / 2; + default: return 0; + } + }; + const back = (x, dir) => { + const c1 = 1.70158; + const c2 = c1 * 1.525; + const c3 = c1 + 1; + switch (dir) { + case "in": return c3 * x * x * x - c1 * x * x; + case "out": return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); + case "in out": return x < 0.5 ? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2 + : (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; + default: return 0; + } + }; + const elastic = (x, dir) => { + const c4 = (2 * Math.PI) / 3; + const c5 = (2 * Math.PI) / 4.5; + switch (dir) { + case "in": return x === 0 ? 0 : x === 1 ? 1 : -Math.pow(2, 10 * x - 10) * Math.sin((x * 10 - 10.75) * c4); + case "out": return x === 0 ? 0 : x === 1 ? 1 : Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * c4) + 1; + case "in out": return x === 0 ? 0 : x === 1 ? 1 : x < 0.5 + ? -(Math.pow(2, 20 * x - 10) * Math.sin((20 * x - 11.125) * c5)) / 2 + : (Math.pow(2, -20 * x + 10) * Math.sin((20 * x - 11.125) * c5)) / 2 + 1; + default: return 0; + } + }; + const bounce = (x, dir) => { + switch (dir) { + case "in": return 1 - bounce(1 - x, "out"); + case "out": { + const n1 = 7.5625, d1 = 2.75; + if (x < 1 / d1) return n1 * x * x; + else if (x < 2 / d1) return n1 * (x -= 1.5 / d1) * x + 0.75; + else if (x < 2.5 / d1) return n1 * (x -= 2.25 / d1) * x + 0.9375; + return n1 * (x -= 2.625 / d1) * x + 0.984375; + } + case "in out": return x < 0.5 ? (1 - bounce(1 - 2 * x, "out")) / 2 : (1 + bounce(2 * x - 1, "out")) / 2; + default: return 0; + } + }; + const EasingMethods = { + linear, sine, quad, cubic, quart, + quint, expo, circ, back, elastic, bounce + }; + + function applyEasing(name, dir, x) { + const fn = EasingMethods[name] || linear; + if (fn === linear) return linear(x); + return fn(x, dir); + } + + let animationsDirtyThisFrame = false; + + function ensureAnimationTicker() { + if (animationTickerInstalled) return; + animationTickerInstalled = true; + runtime.on('BEFORE_EXECUTE', tickCharAnimations); + } + + function tickCharAnimations() { + if (!charAnimations.size) return; + if (animationsDirtyThisFrame) return; + animationsDirtyThisFrame = true; + requestAnimationFrame(computeCharAnimationsFrame); + } + + function computeCharAnimationsFrame() { + animationsDirtyThisFrame = false; + if (!charAnimations.size) return; + const t = nowMs(); + const dirtyStates = new Set(); + for (const [key, anim] of charAnimations) { + const state = getState(anim.target); + const o = getCharOverride(state, anim.index); + const elapsed = t - anim.startTime; + const rawProgress = anim.duration > 0 ? Math.min(1, elapsed / anim.duration) : 1; + const progress = applyEasing(anim.easing, anim.direction, rawProgress); + o[anim.property] = anim.from + (anim.to - anim.from) * progress; + dirtyStates.add(anim.target); + if (rawProgress >= 1) { + o[anim.property] = anim.to; + charAnimations.delete(key); + } + } + for (const target of dirtyStates) { + schedulePaint(target, getState(target)); + } + } + + let renderFlushLoopRunning = false; + + function renderFlushLoop() { + flushPendingRenders(); + if (pendingRenderTargets.size) { + requestAnimationFrame(renderFlushLoop); + } else { + renderFlushLoopRunning = false; + } + } + + function ensureRenderFlushTicker() { + if (renderFlushLoopRunning) return; + renderFlushLoopRunning = true; + requestAnimationFrame(renderFlushLoop); + } function requestRenderFlush() { - if (renderFlushScheduled) return; - renderFlushScheduled = true; - Promise.resolve().then(flushPendingRenders); + ensureRenderFlushTicker(); } function scheduleRender(target) { @@ -1051,14 +1649,13 @@ Enjoy!! :D } function flushPendingRenders() { - renderFlushScheduled = false; + if (!pendingRenderTargets.size) return; const renderTargets = new Set(pendingRenderTargets); for (const target of renderTargets) { pendingRenderTargets.delete(target); const state = getState(target); if (state.visible) renderTarget(target); } - if (pendingRenderTargets.size) requestRenderFlush(); } function flushRenderIfDirty(target) { @@ -1083,11 +1680,17 @@ Enjoy!! :D return getShapeKey(state) + '\u0002' + state.charOverridesVersion; } + function getPaintOpsKey(state, layoutKey) { + return layoutKey + '\u0004' + state.align + '\u0004' + state.charMasksVersion; + } + const EMPTY_OVERRIDE = { x: 0, y: 0, rotation: 0, - opacity: 1 + opacity: 1, + scale: 1, + color: null }; function addTagIndex(charsByTag, index, tags) { @@ -1108,20 +1711,24 @@ Enjoy!! :D if (!state.visible) return; const shapeKey = getShapeKey(state); + const layoutKey = getLayoutKey(state); let layout = state.layout; - if (state.shapeKey !== shapeKey || !layout) { + if (state.shapeKey !== shapeKey || state.layoutKey !== layoutKey || !layout || state.fontsPendingAtMeasure) { const richChars = parseRichText(state.rawText, state.baseStyle); applyCharacterStyleOverrides(richChars, state.charStyleOverrides); - for (const family of new Set(richChars.map(rc => rc.font))) { + const families = new Set(richChars.map(rc => rc.font)); + let fontsPending = false; + for (const family of families) { + if (!loadedDocumentFonts.has(family)) fontsPending = true; ensureDocumentFont(family); } - const svg = getMeasureSvg(); - const measured = measureRichText(svg, richChars, state); + const measured = measureRichText(glyphMeasureCtx, richChars, state); layout = measured; state.shapeKey = shapeKey; state.layout = layout; + state.fontsPendingAtMeasure = fontsPending; } - state.layoutKey = getLayoutKey(state); + state.layoutKey = layoutKey; const { lines, lineWidths, @@ -1150,111 +1757,129 @@ Enjoy!! :D const originX = docW / 2; const originY = docH / 2; + const paintOpsKey = getPaintOpsKey(state, layoutKey) + '\u0004' + docW + '\u0004' + docH; + let paintOps = state.paintOps; let charBoxes = state.charBoxes; - if (state.charBoxesLayout !== layout) { - charBoxes = []; - state.charBoxes = charBoxes; - state.charBoxesLayout = layout; - } - const charsByTag = layout.charsByTag; - const paintOps = []; - const letterSpacing = state.letterSpacing || 0; - for (let li = 0; li < lines.length; li++) { - const line = lines[li]; - const runs = lineRuns[li]; - const lineW = lineWidths[li]; - const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && - effectiveMaxWidth > lineW ? - line.filter(rc => rc.char === ' ').length : 0; - const justifyExtra = justifySpaceCount > 0 ? - (effectiveMaxWidth - lineW) / justifySpaceCount : 0; - let lineX; - - if (state.align === 'left') { - lineX = originX - effectiveMaxWidth / 2; - } else if (state.align === 'right') { - lineX = originX + effectiveMaxWidth / 2 - lineW; - } else if (state.align === 'justify') { - lineX = originX - effectiveMaxWidth / 2; - } else { - lineX = originX - lineW / 2; + if (state.paintOpsKey !== paintOpsKey || state.paintOpsLayout !== layout || !paintOps) { + if (state.charBoxesLayout !== layout) { + charBoxes = []; + state.charBoxesLayout = layout; } + const charsByTag = layout.charsByTag; + paintOps = []; + const letterSpacing = state.letterSpacing || 0; + + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + const runs = lineRuns[li]; + const lineW = lineWidths[li]; + const justifySpaceCount = state.align === 'justify' && li < lines.length - 1 && + effectiveMaxWidth > lineW ? + line.filter(rc => rc.char === ' ').length : 0; + const justifyExtra = justifySpaceCount > 0 ? + (effectiveMaxWidth - lineW) / justifySpaceCount : 0; + let lineX; + + if (state.align === 'left') { + lineX = originX - effectiveMaxWidth / 2; + } else if (state.align === 'right') { + lineX = originX + effectiveMaxWidth / 2 - lineW; + } else if (state.align === 'justify') { + lineX = originX - effectiveMaxWidth / 2; + } else { + lineX = originX - lineW / 2; + } - const textStartY = originY - totalHeight / 2; - const baseCenterY = textStartY + (li * lineHeight) + lineHeight * 0.5; - - let spacesBefore = 0; - for (const run of runs) { - const rc0 = run.chars[0]; - - const underline = !!rc0.underline; - const strike = !!rc0.strike; - const style = run.fontStyle; - const runOriginX = lineX + run.runStartX; - - for (let i = 0; i < run.chars.length; i++) { - const rc = run.chars[i]; - const pos = run.positions[i]; - const index = run.startIndex + i; - const o = run.hasOverride ? - (state.charOverrides[index] || EMPTY_OVERRIDE) : - EMPTY_OVERRIDE; - - const charCenterX = runOriginX + pos.shapedX + pos.advance / 2 + - spacesBefore * justifyExtra + o.x; - const charCenterY = baseCenterY - o.y; - const rotation = o.rotation || 0; - const opacity = o.opacity == null ? 1 : o.opacity; - const ch = rc.char === ' ' ? '\u00A0' : rc.char; - - paintOps.push({ - text: ch, - x: charCenterX, - y: charCenterY, - rotation, - opacity, - color: rc.color, - font: style, - rawFontFamily: rc0.font, - letterSpacing, - underline, - strike, - width: pos.advance - }); - - if (rc.char === ' ') spacesBefore++; - - let box = charBoxes[index]; - if (!box) { - box = { - index, - char: rc.char, - tags: rc.tags, + const textStartY = originY - totalHeight / 2; + const baseCenterY = textStartY + (li * lineHeight) + lineHeight * 0.5; + + let spacesBefore = 0; + for (const run of runs) { + const rc0 = run.chars[0]; + + const underline = !!rc0.underline; + const strike = !!rc0.strike; + const style = run.fontStyle; + const runOriginX = lineX + run.runStartX; + + for (let i = 0; i < run.chars.length; i++) { + const rc = run.chars[i]; + const pos = run.positions[i]; + const index = run.startIndex + i; + const o = run.hasOverride ? + (state.charOverrides[index] || EMPTY_OVERRIDE) : + EMPTY_OVERRIDE; + + const charCenterX = runOriginX + pos.shapedX + pos.advance / 2 + + spacesBefore * justifyExtra + o.x; + const charCenterY = baseCenterY - o.y; + const rotation = o.rotation || 0; + const opacity = o.opacity == null ? 1 : o.opacity; + const scale = o.scale == null ? 1 : o.scale; + const ch = rc.char === ' ' ? '\u00A0' : rc.char; + const mask = state.charMasks[index] || null; + + paintOps.push({ + text: ch, + x: charCenterX, + y: charCenterY, + rotation, + opacity, + scale, + color: o.color || rc.color, + font: style, + rawFontFamily: rc0.font, + letterSpacing, + underline, + strike, width: pos.advance, - height: rc.size, - style: characterStyleSnapshot(rc) - }; - charBoxes[index] = box; + mask, + charIndex: index + }); + + if (rc.char === ' ') spacesBefore++; + + let box = charBoxes[index]; + if (!box) { + box = { + index, + char: rc.char, + tags: rc.tags, + width: pos.advance, + height: rc.size, + style: characterStyleSnapshot(rc) + }; + charBoxes[index] = box; + } else if (!styleSnapshotMatches(box.style, rc)) { + box.style = characterStyleSnapshot(rc); + } + box.x = charCenterX - originX; + box.y = originY - charCenterY; } - box.style = characterStyleSnapshot(rc); - box.x = charCenterX - originX; - box.y = originY - charCenterY; } } - } - state.charBoxes = charBoxes; - state.charsByTag = charsByTag; - state.drawableWidth = docW; - state.drawableHeight = docH; + state.paintOps = paintOps; + state.paintOpsKey = paintOpsKey; + state.paintOpsLayout = layout; + state.charBoxes = charBoxes; + state.charsByTag = charsByTag; + state.drawableWidth = docW; + state.drawableHeight = docH; - const fingerprint = fingerprintPaintOps(paintOps, docW, docH); - if (fingerprint === state.paintFingerprint && state.skinId !== null) { - state.paintDirty = false; - return; + const fingerprint = fingerprintPaintOps(paintOps, docW, docH); + if (fingerprint === state.paintFingerprint && state.skinId !== null) { + state.paintDirty = false; + return; + } + state.paintFingerprint = fingerprint; + } else { + if (state.paintFingerprint !== null && state.skinId !== null) { + state.paintDirty = false; + return; + } } - state.paintFingerprint = fingerprint; const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); pushCanvasToDrawable(target, canvas, docW, docH); @@ -1293,6 +1918,91 @@ Enjoy!! :D const DEST_SCALE = GLYPH_OVERSAMPLE; + function maskGroupKey(mask) { + return mask.targetName + '\u0001' + mask.costumeName + '\u0001' + + (mask.direction || WIPE_DIRECTION_BOTTOM_UP) + '\u0001' + + Math.round(mask.x * 4) + '\u0001' + Math.round(mask.y * 4); + } + + function computeSeamlessMaskSpansByIndex(paintOps) { + const spans = new Map(); + let groupIndices = null; + let groupKey = null; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + + const flushGroup = () => { + if (!groupIndices || !groupIndices.length) return; + if (!isFinite(minX) || !isFinite(maxX) || !isFinite(minY) || !isFinite(maxY)) return; + const spanW = Math.max(1, maxX - minX); + const spanH = Math.max(1, maxY - minY); + for (const gi of groupIndices) { + spans.set(gi, { + spanW, + spanH, + originX: minX, + originY: minY + }); + } + }; + + for (let i = 0; i < paintOps.length; i++) { + const op = paintOps[i]; + if (!op.mask || !op.mask.seamless) { + flushGroup(); + groupIndices = null; + groupKey = null; + continue; + } + + const key = maskGroupKey(op.mask); + if (groupKey !== key) { + flushGroup(); + groupIndices = []; + groupKey = key; + minX = Infinity; + maxX = -Infinity; + minY = Infinity; + maxY = -Infinity; + } + groupIndices.push(op.charIndex); + + if (op.text === '\u00A0' || op.text === '') continue; + + const fontFamily = op.font['font-family']; + const fontSize = op.font['font-size']; + const fontWeight = op.font['font-weight']; + const fontStyle = op.font['font-style']; + const shape = getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); + const advanceCenterXEm = shape.baselineOriginXEm + shape.advance / 2; + const baselineYFromLineCenter = (shape.fontAscentEm - shape.fontDescentEm) / 2; + const drawXEm = op.x - advanceCenterXEm; + const drawYEm = (op.y + baselineYFromLineCenter) - shape.baselineOriginYEm; + const px = Math.round(drawXEm * DEST_SCALE); + const py = Math.round(drawYEm * DEST_SCALE); + const w = shape.canvas.width; + const h = shape.canvas.height; + + minX = Math.min(minX, px); + maxX = Math.max(maxX, px + w); + minY = Math.min(minY, py); + maxY = Math.max(maxY, py + h); + } + flushGroup(); + + return spans; + } + + function getSeamlessMaskSpans(state, paintOps, layoutDocKey) { + const cacheKey = layoutDocKey + '\u0003' + state.charMaskGeometryVersion; + if (state.maskSpansCacheKey === cacheKey && state.maskSpansCache) { + return state.maskSpansCache; + } + const spans = computeSeamlessMaskSpansByIndex(paintOps); + state.maskSpansCacheKey = cacheKey; + state.maskSpansCache = spans; + return spans; + } + function compositeGlyphsToCanvas(state, paintOps, docW, docH, textWidth, textHeight, originX, originY) { let canvas = state.paintCanvas; if (!canvas) { @@ -1330,6 +2040,17 @@ Enjoy!! :D ctx.restore(); } + const maskSpansByIndex = getSeamlessMaskSpans(state, paintOps, state.layoutKey + '\u0003' + docW + '\u0003' + docH); + + const applyCharTransformOp = (op, hasRotation, hasScale) => { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.translate(cx, cy); + if (hasRotation) ctx.rotate(-op.rotation * Math.PI / 180); + if (hasScale) ctx.scale(op.scale, op.scale); + ctx.translate(-cx, -cy); + }; + for (let i = 0; i < paintOps.length; i++) { const op = paintOps[i]; if (op.text === '\u00A0' || op.text === '') continue; @@ -1348,10 +2069,8 @@ Enjoy!! :D const hasOpacity = op.opacity != null && op.opacity !== 1; const hasRotation = !!op.rotation; - - const drawGlyph = (drawX, drawY) => { - ctx.drawImage(glyph.canvas, drawX, drawY); - }; + const hasScale = op.scale != null && op.scale !== 1; + const hasTransform = hasRotation || hasScale; if (state.textShadow.enabled) { ctx.save(); @@ -1360,15 +2079,11 @@ Enjoy!! :D ctx.shadowBlur = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; ctx.shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE; ctx.shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE; - if (!hasRotation) { - drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + if (!hasTransform) { + ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); } else { - const cx = op.x * DEST_SCALE; - const cy = op.y * DEST_SCALE; - ctx.translate(cx, cy); - ctx.rotate(-op.rotation * Math.PI / 180); - ctx.translate(-cx, -cy); - drawGlyph(drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + applyCharTransformOp(op, hasRotation, hasScale); + ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); } ctx.restore(); } @@ -1396,15 +2111,11 @@ Enjoy!! :D const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; for (const [offsetX, offsetY] of borderOffsets) { - if (!hasRotation) { + if (!hasTransform) { ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); } else { - const cx = op.x * DEST_SCALE; - const cy = op.y * DEST_SCALE; ctx.save(); - ctx.translate(cx, cy); - ctx.rotate(-op.rotation * Math.PI / 180); - ctx.translate(-cx, -cy); + applyCharTransformOp(op, hasRotation, hasScale); ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); ctx.restore(); } @@ -1412,7 +2123,7 @@ Enjoy!! :D ctx.restore(); } - if (!hasRotation) { + if (!hasTransform) { const px = Math.round(drawXEm * DEST_SCALE); const py = Math.round(drawYEm * DEST_SCALE); if (hasOpacity) { @@ -1426,15 +2137,44 @@ Enjoy!! :D } else { ctx.save(); if (hasOpacity) ctx.globalAlpha = op.opacity; - const cx = op.x * DEST_SCALE; - const cy = op.y * DEST_SCALE; - ctx.translate(cx, cy); - ctx.rotate(-op.rotation * Math.PI / 180); - ctx.translate(-cx, -cy); + applyCharTransformOp(op, hasRotation, hasScale); ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); ctx.restore(); } + if (op.mask) { + const texture = getMaskTexture(op.mask.targetName, op.mask.costumeName); + if (texture) { + const px = Math.round(drawXEm * DEST_SCALE); + const py = Math.round(drawYEm * DEST_SCALE); + const span = maskSpansByIndex.get(op.charIndex); + const maskOpacity = Math.max(0, Math.min(100, Number(op.mask.opacity != null ? op.mask.opacity : 100))) / 100; + const combinedAlpha = (hasOpacity ? op.opacity : 1) * maskOpacity; + if (combinedAlpha > 0) { + if (!hasTransform) { + drawMaskedGlyph( + ctx, op.text, fontFamily, fontSize, fontWeight, fontStyle, + op.mask, texture, px, py, + span ? span.spanW : 0, span ? span.spanH : 0, + span ? span.originX : 0, span ? span.originY : 0, + px, py, combinedAlpha + ); + } else { + ctx.save(); + applyCharTransformOp(op, hasRotation, hasScale); + drawMaskedGlyph( + ctx, op.text, fontFamily, fontSize, fontWeight, fontStyle, + op.mask, texture, px, py, + span ? span.spanW : 0, span ? span.spanH : 0, + span ? span.originX : 0, span ? span.originY : 0, + drawXEm * DEST_SCALE, drawYEm * DEST_SCALE, combinedAlpha + ); + ctx.restore(); + } + } + } + } + if (op.underline) { ctx.save(); if (hasOpacity) ctx.globalAlpha = op.opacity; @@ -1512,7 +2252,7 @@ Enjoy!! :D index }; target._irisTypingChar = current; - const threads = runtime.startHats('irisText_onCharacterType', null, target); + const threads = runtime.startHats('g1nxIrisText_onCharacterType', null, target); if (Array.isArray(threads)) { for (const thread of threads) { thread._irisChar = current; @@ -1521,6 +2261,10 @@ Enjoy!! :D target._irisTypingChar = null; } + function startTypingFinishedHat(target) { + runtime.startHats('g1nxIrisText_onTypingFinished', null, target); + } + function taggedCharacterIndices(target, tag) { const state = getState(target); const chars = parseRichText(state.rawText, state.baseStyle); @@ -1537,6 +2281,7 @@ Enjoy!! :D runtime.on('targetWasRemoved', this._onTargetRemoved); runtime.on('PROJECT_STOP_ALL', () => { runtime.targets.forEach(t => clearTarget(t)); + charAnimations.clear(); }); runtime.ext_irisText = this; if (typeof runtime.registerCompiledExtensionBlocks === 'function') { @@ -1599,6 +2344,9 @@ Enjoy!! :D runtime.renderer.destroySkin(state.skinId); state.skinId = null; } + for (const key of charAnimations.keys()) { + if (key.startsWith(target.id + '\u0001')) charAnimations.delete(key); + } } getInfo() { @@ -1732,6 +2480,7 @@ Enjoy!! :D } } }, + '---', { opcode: 'skipTyping', blockType: Scratch.BlockType.COMMAND, @@ -1742,6 +2491,7 @@ Enjoy!! :D blockType: Scratch.BlockType.COMMAND, text: 'stop typing' }, + '---', { opcode: 'setTypingSpeed', blockType: Scratch.BlockType.COMMAND, @@ -1773,6 +2523,7 @@ Enjoy!! :D } } }, + '---', { opcode: 'onCharacterType', blockType: Scratch.BlockType.EVENT, @@ -1781,9 +2532,11 @@ Enjoy!! :D text: 'on character type' }, { - opcode: 'currentCharValue', - blockType: Scratch.BlockType.REPORTER, - text: 'character' + opcode: 'onTypingFinished', + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: false, + text: 'when typing finishes' }, { blockType: Scratch.BlockType.LABEL, @@ -1801,6 +2554,7 @@ Enjoy!! :D } } }, + '---', { opcode: 'setTextShadowColor', blockType: Scratch.BlockType.COMMAND, @@ -1864,6 +2618,7 @@ Enjoy!! :D } } }, + '---', { opcode: 'setTextBackgroundColor', blockType: Scratch.BlockType.COMMAND, @@ -2057,6 +2812,18 @@ Enjoy!! :D } } }, + { + opcode: 'repeatForEachChar', + blockType: Scratch.BlockType.LOOP, + text: 'repeat for each character [CHAR]', + branchCount: 1, + arguments: { + CHAR: { + type: Scratch.ArgumentType.STRING, + fillIn: 'currentCharIndex' + } + } + }, { opcode: 'currentCharIndex', blockType: Scratch.BlockType.REPORTER, @@ -2080,6 +2847,7 @@ Enjoy!! :D } } }, + '---', { opcode: 'setCharPos', blockType: Scratch.BlockType.COMMAND, @@ -2138,6 +2906,7 @@ Enjoy!! :D } } }, + '---', { opcode: 'setCharRotation', blockType: Scratch.BlockType.COMMAND, @@ -2168,10 +2937,11 @@ Enjoy!! :D } } }, + '---', { - opcode: 'setCharOpacity', + opcode: 'setCharScale', blockType: Scratch.BlockType.COMMAND, - text: 'set character [INDEX] opacity to [PCT] %', + text: 'set character [INDEX] scale to [PCT] %', arguments: { INDEX: { type: Scratch.ArgumentType.NUMBER, @@ -2184,15 +2954,103 @@ Enjoy!! :D } }, { - opcode: 'resetCharTransform', + opcode: 'setCharOpacity', blockType: Scratch.BlockType.COMMAND, - text: 'reset character [INDEX] transform', + text: 'set character [INDEX] opacity to [PCT] %', arguments: { INDEX: { type: Scratch.ArgumentType.NUMBER, defaultValue: 1 - } - } + }, + PCT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + '---', + { + opcode: 'setCharColor', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [INDEX] color to [COLOR]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + COLOR: { + type: Scratch.ArgumentType.COLOR + } + } + }, + { + opcode: 'clearCharColor', + blockType: Scratch.BlockType.COMMAND, + text: 'clear character [INDEX] color override', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + '---', + { + opcode: 'animateChar', + blockType: Scratch.BlockType.COMMAND, + text: 'animate character [INDEX] [PROPERTY] to [VALUE] over [SECS] secs easing [EASING] [DIRECTION]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + PROPERTY: { + type: Scratch.ArgumentType.STRING, + menu: 'ANIMATE_PROPERTY', + defaultValue: 'y' + }, + VALUE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + }, + SECS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + EASING: { + type: Scratch.ArgumentType.STRING, + menu: 'EASING', + defaultValue: 'linear' + }, + DIRECTION: { + type: Scratch.ArgumentType.STRING, + menu: 'EASING_DIRECTION', + defaultValue: 'out' + } + } + }, + { + opcode: 'stopCharAnimations', + blockType: Scratch.BlockType.COMMAND, + text: 'stop animations for character [INDEX]', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + '---', + { + opcode: 'resetCharTransform', + blockType: Scratch.BlockType.COMMAND, + text: 'reset character [INDEX] transform', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } }, { opcode: 'resetAllCharTransforms', @@ -2240,6 +3098,243 @@ Enjoy!! :D } } }, + { + blockType: Scratch.BlockType.LABEL, + text: 'Character Mask' + }, + { + opcode: 'setCharacterMask', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [START] to [END] mask to costume [COSTUME]', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + COSTUME: { + type: Scratch.ArgumentType.STRING, + menu: 'MASK_COSTUME' + } + } + }, + '---', + { + opcode: 'setCharacterMaskCoverage', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [START] to [END] mask coverage to [COVERAGE] %', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + COVERAGE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'setCharacterMaskDirection', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [START] to [END] mask direction to [DIRECTION]', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + DIRECTION: { + type: Scratch.ArgumentType.STRING, + menu: 'WIPE_DIRECTION' + } + } + }, + { + opcode: 'setCharacterMaskSeamless', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [START] to [END] mask seamless [SEAMLESS]', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + SEAMLESS: { + type: Scratch.ArgumentType.STRING, + menu: 'ONOFF' + } + } + }, + '---', + { + opcode: 'setCharacterMaskOpacity', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [START] to [END] mask opacity to [OPACITY] %', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + OPACITY: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'setCharacterMaskBlur', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [START] to [END] mask blur to [BLUR]', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + BLUR: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 2 + } + } + }, + '---', + { + opcode: 'setCharacterMaskPosition', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [START] to [END] mask position to x [X] y [Y]', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'changeCharacterMaskPosition', + blockType: Scratch.BlockType.COMMAND, + text: 'change character [START] to [END] mask position by x [X] y [Y]', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + X: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + }, + Y: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + { + opcode: 'setCharacterMaskZoom', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [START] to [END] mask zoom to [ZOOM] %', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + ZOOM: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100 + } + } + }, + { + opcode: 'setCharacterMaskRotation', + blockType: Scratch.BlockType.COMMAND, + text: 'set character [START] to [END] mask rotation to [ROTATION]', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + ROTATION: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0 + } + } + }, + '---', + { + opcode: 'clearCharacterMask', + blockType: Scratch.BlockType.COMMAND, + text: 'clear character [START] to [END] mask', + arguments: { + START: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + }, + END: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'clearAllCharacterMasks', + blockType: Scratch.BlockType.COMMAND, + text: 'clear all character masks' + }, + { + opcode: 'characterHasMask', + blockType: Scratch.BlockType.BOOLEAN, + text: 'character [INDEX] has a mask?', + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, { blockType: Scratch.BlockType.LABEL, text: 'Text Info' @@ -2277,6 +3372,7 @@ Enjoy!! :D } } }, + '---', { opcode: 'getCharCount', blockType: Scratch.BlockType.REPORTER, @@ -2404,8 +3500,23 @@ Enjoy!! :D SPRITE: { items: 'getSpriteMenuItems' }, + MASK_COSTUME: { + items: 'getMaskCostumeMenuItems' + }, + WIPE_DIRECTION: { + items: [WIPE_DIRECTION_BOTTOM_UP, WIPE_DIRECTION_LEFT_RIGHT, WIPE_DIRECTION_UP_DOWN, WIPE_DIRECTION_RIGHT_LEFT] + }, AXIS: { items: ['x', 'y'] + }, + ANIMATE_PROPERTY: { + items: ['x', 'y', 'rotation', 'opacity', 'scale'] + }, + EASING: { + items: ['linear', 'sine', 'quad', 'cubic', 'quart', 'quint', 'expo', 'circ', 'back', 'elastic', 'bounce'] + }, + EASING_DIRECTION: { + items: ['in', 'out', 'in out'] } } }; @@ -2499,7 +3610,7 @@ Enjoy!! :D state.rawText = frame.irisTypedText; state.typingControl = null; schedulePaint(util.target, state); - flushRenderIfDirty(util.target); + startTypingFinishedHat(util.target); return; } @@ -2518,7 +3629,6 @@ Enjoy!! :D state.rawText = frame.irisTypedText; schedulePaint(util.target, state); - flushRenderIfDirty(util.target); startTypingCharacterHat(util.target, step.char, frame.irisTypingCharIndex++); const delay = Math.max(0, Number(typingDelayForCharacter(state, step.char)) || 0); @@ -2531,6 +3641,7 @@ Enjoy!! :D state.rawText = frame.irisTypedText; schedulePaint(util.target, state); + startTypingFinishedHat(util.target); } typeText(args, util) { @@ -2556,7 +3667,8 @@ Enjoy!! :D renderNow(args, util) { const state = getState(util.target); if (!state.visible) return; - scheduleTextRender(util.target); + if (!state.paintDirty && !pendingRenderTargets.has(util.target)) return; + flushRenderIfDirty(util.target); } setBaseColor(args, util) { @@ -2610,6 +3722,32 @@ Enjoy!! :D return items; } + getMaskCostumeMenuItems() { + const items = []; + const seen = new Set(); + for (const target of runtime.targets) { + if (target.isOriginal === false) continue; + const targetName = target.isStage ? 'Stage' : target.getName(); + const costumes = target.getCostumes ? target.getCostumes() : []; + for (const costume of costumes) { + const value = targetName + ': ' + costume.name; + if (seen.has(value)) continue; + seen.add(value); + items.push({ + text: value, + value + }); + } + } + if (!items.length) { + items.push({ + text: '(no costumes found)', + value: '' + }); + } + return items; + } + exportSettings(args, util) { return exportTextSettings(getState(util.target)); } @@ -2814,6 +3952,27 @@ Enjoy!! :D util.startBranch(1, true); } + repeatForEachChar(args, util) { + const frame = util.stackFrame; + const rootFrame = util.thread.stackFrames[0]; + let loop = frame.irisEachCharLoop; + + if (!loop) { + const state = getState(util.target); + loop = { + count: stripMarkup(state.rawText).length, + position: 0 + }; + frame.irisEachCharLoop = loop; + rootFrame.irisTagCharacterNumber = 0; + } + + if (loop.position >= loop.count) return; + rootFrame.irisTagCharacterNumber = loop.position + 1; + loop.position++; + util.startBranch(1, true); + } + currentCharValue(args, util) { const cur = (util.thread && util.thread._irisChar) || util.target._irisTypingChar; return cur ? cur.char : ''; @@ -2904,9 +4063,89 @@ Enjoy!! :D schedulePaint(util.target, state); } + setCharColor(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const color = Scratch.Cast.toString(args.COLOR); + const exists = !!state.charOverrides[idx]; + const o = getCharOverride(state, idx); + if (exists && o.color === color) return; + o.color = color; + schedulePaint(util.target, state); + } + + clearCharColor(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const o = state.charOverrides[idx]; + if (!o || o.color === null) return; + o.color = null; + schedulePaint(util.target, state); + } + + setCharScale(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const scale = Math.max(0, Scratch.Cast.toNumber(args.PCT)) / 100; + const exists = !!state.charOverrides[idx]; + const o = getCharOverride(state, idx); + if (exists && o.scale === scale) return; + o.scale = scale; + schedulePaint(util.target, state); + } + + animateChar(args, util) { + const target = util.target; + const state = getState(target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const property = Scratch.Cast.toString(args.PROPERTY); + const validProps = ['x', 'y', 'rotation', 'opacity', 'scale']; + if (!validProps.includes(property)) return; + let targetValue = Scratch.Cast.toNumber(args.VALUE); + if (property === 'opacity') targetValue = Math.max(0, Math.min(100, targetValue)) / 100; + if (property === 'scale') targetValue = Math.max(0, targetValue) / 100; + const duration = Math.max(0, Scratch.Cast.toNumber(args.SECS)); + const easingName = Scratch.Cast.toString(args.EASING); + const easing = Object.prototype.hasOwnProperty.call(EasingMethods, easingName) ? easingName : 'linear'; + const direction = Scratch.Cast.toString(args.DIRECTION); + const o = getCharOverride(state, idx); + + const key = target.id + '\u0001' + idx + '\u0001' + property; + if (duration <= 0) { + charAnimations.delete(key); + o[property] = targetValue; + schedulePaint(target, state); + return; + } + + charAnimations.set(key, { + target, + state, + index: idx, + property, + easing, + direction, + from: o[property] == null ? (property === 'scale' || property === 'opacity' ? 1 : 0) : o[property], + to: targetValue, + startTime: nowMs(), + duration: duration * 1000 + }); + ensureAnimationTicker(); + } + + stopCharAnimations(args, util) { + const target = util.target; + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + const prefix = target.id + '\u0001' + idx + '\u0001'; + for (const key of charAnimations.keys()) { + if (key.startsWith(prefix)) charAnimations.delete(key); + } + } + resetCharTransform(args, util) { const state = getState(util.target); const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + this.stopCharAnimations(args, util); if (state.charOverrides[idx]) { delete state.charOverrides[idx]; state.charOverridesVersion++; @@ -2916,6 +4155,10 @@ Enjoy!! :D resetAllCharTransforms(args, util) { const state = getState(util.target); + const target = util.target; + for (const key of charAnimations.keys()) { + if (key.startsWith(target.id + '\u0001')) charAnimations.delete(key); + } if (Object.keys(state.charOverrides).length) { state.charOverrides = {}; state.charOverridesVersion++; @@ -2966,6 +4209,272 @@ Enjoy!! :D schedulePaint(util.target, state); } + setCharacterMask(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const ref = Scratch.Cast.toString(args.COSTUME); + const sepIndex = ref.indexOf(': '); + if (sepIndex === -1) return; + const targetName = ref.slice(0, sepIndex); + const costumeName = ref.slice(sepIndex + 2); + if (!targetName || !costumeName) return; + + getMaskTexture(targetName, costumeName); + + let paintNeeded = false; + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (existing && existing.targetName === targetName && existing.costumeName === costumeName) { + continue; + } + setCharMaskForIndex(state, idx, { + targetName, + costumeName, + coverage: existing ? existing.coverage : 100, + opacity: existing ? existing.opacity : 100, + blur: existing ? existing.blur : 2, + x: existing ? existing.x : 0, + y: existing ? existing.y : 0, + zoom: existing ? existing.zoom : 100, + rotation: existing ? existing.rotation : 0, + direction: existing ? existing.direction : WIPE_DIRECTION_BOTTOM_UP, + seamless: existing ? existing.seamless : false + }); + paintNeeded = true; + } + if (paintNeeded) schedulePaint(util.target, state); + } + + setCharacterMaskCoverage(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const coverage = Scratch.Cast.toNumber(args.COVERAGE); + let changed = false; + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (!existing) continue; + if (existing.coverage === coverage) continue; + existing.coverage = coverage; + changed = true; + } + if (changed) { + state.charMasksVersion++; + schedulePaint(util.target, state); + } + } + + setCharacterMaskOpacity(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const opacity = Scratch.Cast.toNumber(args.OPACITY); + let changed = false; + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (!existing) continue; + if (existing.opacity === opacity) continue; + existing.opacity = opacity; + changed = true; + } + if (changed) { + state.charMasksVersion++; + schedulePaint(util.target, state); + } + } + + setCharacterMaskBlur(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const blur = Scratch.Cast.toNumber(args.BLUR); + let changed = false; + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (!existing) continue; + if (existing.blur === blur) continue; + existing.blur = blur; + changed = true; + } + if (changed) { + state.charMasksVersion++; + schedulePaint(util.target, state); + } + } + + setCharacterMaskPosition(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const x = Scratch.Cast.toNumber(args.X); + const y = Scratch.Cast.toNumber(args.Y); + let changed = false; + let geometryChanged = false; + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (!existing) continue; + if (existing.x === x && existing.y === y) continue; + geometryChanged = true; + existing.x = x; + existing.y = y; + changed = true; + } + if (changed) { + state.charMasksVersion++; + if (geometryChanged) state.charMaskGeometryVersion++; + schedulePaint(util.target, state); + } + } + + changeCharacterMaskPosition(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const dx = Scratch.Cast.toNumber(args.X); + const dy = Scratch.Cast.toNumber(args.Y); + let changed = false; + if (dx !== 0 || dy !== 0) { + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (!existing) continue; + existing.x = (existing.x || 0) + dx; + existing.y = (existing.y || 0) + dy; + changed = true; + } + } + if (changed) { + state.charMasksVersion++; + schedulePaint(util.target, state); + } + } + + setCharacterMaskZoom(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const zoom = Scratch.Cast.toNumber(args.ZOOM); + let changed = false; + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (!existing) continue; + if (existing.zoom === zoom) continue; + existing.zoom = zoom; + changed = true; + } + if (changed) { + state.charMasksVersion++; + schedulePaint(util.target, state); + } + } + + setCharacterMaskRotation(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const rotation = Scratch.Cast.toNumber(args.ROTATION); + let changed = false; + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (!existing) continue; + if (existing.rotation === rotation) continue; + existing.rotation = rotation; + changed = true; + } + if (changed) { + state.charMasksVersion++; + schedulePaint(util.target, state); + } + } + + setCharacterMaskDirection(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const direction = Scratch.Cast.toString(args.DIRECTION) || WIPE_DIRECTION_BOTTOM_UP; + let changed = false; + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (!existing) continue; + if (existing.direction === direction) continue; + existing.direction = direction; + changed = true; + } + if (changed) { + state.charMasksVersion++; + state.charMaskGeometryVersion++; + schedulePaint(util.target, state); + } + } + + setCharacterMaskSeamless(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + const seamless = Scratch.Cast.toString(args.SEAMLESS) === 'on'; + let changed = false; + for (let idx = start; idx <= end; idx++) { + const existing = state.charMasks[idx]; + if (!existing) continue; + if (existing.seamless === seamless) continue; + existing.seamless = seamless; + changed = true; + } + if (changed) { + state.charMasksVersion++; + state.charMaskGeometryVersion++; + schedulePaint(util.target, state); + } + } + + clearCharacterMask(args, util) { + const state = getState(util.target); + const { + start, + end + } = indexRange(Scratch.Cast.toNumber(args.START), Scratch.Cast.toNumber(args.END)); + let changed = false; + for (let idx = start; idx <= end; idx++) { + if (clearCharMaskForIndex(state, idx)) changed = true; + } + if (changed) schedulePaint(util.target, state); + } + + clearAllCharacterMasks(args, util) { + const state = getState(util.target); + if (!Object.keys(state.charMasks).length) return; + state.charMasks = {}; + state.charMasksVersion++; + state.charMaskGeometryVersion++; + schedulePaint(util.target, state); + } + + characterHasMask(args, util) { + const state = getState(util.target); + const idx = Scratch.Cast.toNumber(args.INDEX) - 1; + return !!state.charMasks[idx]; + } + getCharX(args, util) { const state = getState(util.target); flushRenderIfDirty(util.target); From 956cb32fa46d4de1afd1c088eed073721d5977dc Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Sat, 22 Aug 2026 17:04:24 -0300 Subject: [PATCH 11/21] fixed font rendering when packaging + optimized shadows --- static/extensions/Gen1x/iris-text.js | 134 +++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 18 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 7ae6d2243..bc9deb119 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -315,9 +315,16 @@ Enjoy!! :D return style; } + let stripMarkupCacheKey = null; + let stripMarkupCacheValue = null; + function stripMarkup(text) { + if (text === stripMarkupCacheKey) return stripMarkupCacheValue; TAG_RE_STRIP.lastIndex = 0; - return text.replace(TAG_RE_STRIP, ''); + const result = text.replace(TAG_RE_STRIP, ''); + stripMarkupCacheKey = text; + stripMarkupCacheValue = result; + return result; } function defaultBaseStyle() { @@ -335,7 +342,11 @@ Enjoy!! :D const WAIT_RE = /\[wait=([0-9]*\.?[0-9]+)\]/gi; + let splitWaitStagesCacheKey = null; + let splitWaitStagesCacheValue = null; + function splitWaitStages(text) { + if (text === splitWaitStagesCacheKey) return splitWaitStagesCacheValue; WAIT_RE.lastIndex = 0; const stages = []; let i = 0; @@ -354,6 +365,8 @@ Enjoy!! :D text: textSoFar, waitAfter: 0 }); + splitWaitStagesCacheKey = text; + splitWaitStagesCacheValue = stages; return stages; } @@ -381,8 +394,8 @@ Enjoy!! :D color: '#000000', opacity: 50, blur: 8, - offsetX: 4, - offsetY: 4 + offsetX: 0, + offsetY: 0 }, textBackground: { enabled: false, @@ -423,7 +436,8 @@ Enjoy!! :D lastFamiliesKey: null, lastFontDefs: '', revealToken: 0, - fontsPendingAtMeasure: false + fontsPendingAtMeasure: false, + hasPaintedOnce: false }; } @@ -709,6 +723,7 @@ Enjoy!! :D return { canvas, tinted: new Map(), + shadows: new Map(), advance: advance / GLYPH_OVERSAMPLE, baselineOriginXEm: baselineX / GLYPH_OVERSAMPLE, baselineOriginYEm: baselineY / GLYPH_OVERSAMPLE, @@ -717,6 +732,38 @@ Enjoy!! :D }; } + const SHADOW_CACHE_LIMIT = 512; + + function getShadowGlyphBitmap(shape, color, blurPx) { + const key = color + '\u0001' + blurPx; + let entry = shape.shadows.get(key); + if (entry) return entry; + + const padPx = Math.ceil(blurPx * 3) + 2; + const w = shape.canvas.width + padPx * 2; + const h = shape.canvas.height + padPx * 2; + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + ctx.shadowColor = color; + ctx.shadowBlur = blurPx; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.drawImage(shape.canvas, padPx, padPx); + + entry = { + canvas, + offsetX: -padPx, + offsetY: -padPx + }; + if (shape.shadows.size >= SHADOW_CACHE_LIMIT) { + shape.shadows.delete(shape.shadows.keys().next().value); + } + shape.shadows.set(key, entry); + return entry; + } + function getGlyphShape(char, fontFamily, size, weight, style) { const key = glyphCacheKey(char, fontFamily, size, weight, style); let entry = glyphCache.get(key); @@ -1722,6 +1769,9 @@ Enjoy!! :D if (!loadedDocumentFonts.has(family)) fontsPending = true; ensureDocumentFont(family); } + if (fontsPending && !state.hasPaintedOnce) { + return; + } const measured = measureRichText(glyphMeasureCtx, richChars, state); layout = measured; state.shapeKey = shapeKey; @@ -1884,10 +1934,13 @@ Enjoy!! :D const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); pushCanvasToDrawable(target, canvas, docW, docH); state.paintDirty = false; + state.hasPaintedOnce = true; } const loadedDocumentFonts = new Set(); const pendingDocumentFonts = new Map(); + const FONT_LOAD_MAX_RETRIES = 8; + const FONT_LOAD_RETRY_DELAY_MS = 150; function rerenderVisibleTextTargets() { for (const candidate of runtime.targets) { @@ -1901,17 +1954,61 @@ Enjoy!! :D } } - function ensureDocumentFont(fontId) { + const FONT_PROBE_TEXT = 'AaBbGgQqWwZz01@#'; + const FONT_PROBE_CONTROL_FAMILIES = ['serif', 'sans-serif', 'monospace']; + let fontProbeCtx = null; + let fontProbeControlWidths = null; + + function getFontProbeCtx() { + if (fontProbeCtx) return fontProbeCtx; + const canvas = document.createElement('canvas'); + fontProbeCtx = canvas.getContext('2d'); + return fontProbeCtx; + } + + function measureFontProbeWidth(fontFamilyCss) { + const ctx = getFontProbeCtx(); + ctx.font = `16px ${fontFamilyCss}`; + return ctx.measureText(FONT_PROBE_TEXT).width; + } + + function getFontProbeControlWidths() { + if (fontProbeControlWidths) return fontProbeControlWidths; + fontProbeControlWidths = FONT_PROBE_CONTROL_FAMILIES.map(measureFontProbeWidth); + return fontProbeControlWidths; + } + + function isFontActuallyRendering(fontId) { + const rawFamily = `"${fontId}"`; + const controlWidths = getFontProbeControlWidths(); + const testedWidths = FONT_PROBE_CONTROL_FAMILIES.map(fallback => + measureFontProbeWidth(`${rawFamily}, ${fallback}`) + ); + for (let i = 0; i < testedWidths.length; i++) { + if (Math.abs(testedWidths[i] - controlWidths[i]) > 0.5) return true; + } + return false; + } + + function ensureDocumentFont(fontId, attempt) { + attempt = attempt || 0; if (!document.fonts || loadedDocumentFonts.has(fontId) || pendingDocumentFonts.has(fontId)) return; - const promise = document.fonts.load(`16px ${cssFontFamily(fontId)}`, 'M') + const fontSpec = `16px ${cssFontFamily(fontId)}`; + const promise = document.fonts.load(fontSpec, 'M') + .catch(() => []) .then(() => { - loadedDocumentFonts.add(fontId); - invalidateGlyphCacheForFamily(cssFontFamily(fontId)); - rerenderVisibleTextTargets(); - }) - .catch(() => {}) - .finally(() => { pendingDocumentFonts.delete(fontId); + const apiReady = typeof document.fonts.check === 'function' ? document.fonts.check(fontSpec, 'M') : true; + const ready = apiReady && isFontActuallyRendering(fontId); + if (ready) { + loadedDocumentFonts.add(fontId); + invalidateGlyphCacheForFamily(cssFontFamily(fontId)); + rerenderVisibleTextTargets(); + } else if (attempt < FONT_LOAD_MAX_RETRIES) { + setTimeout(() => ensureDocumentFont(fontId, attempt + 1), FONT_LOAD_RETRY_DELAY_MS); + } else { + loadedDocumentFonts.add(fontId); + } }); pendingDocumentFonts.set(fontId, promise); } @@ -2073,17 +2170,18 @@ Enjoy!! :D const hasTransform = hasRotation || hasScale; if (state.textShadow.enabled) { + const shadowShape = getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); + const shadowBlurPx = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; + const shadowBitmap = getShadowGlyphBitmap(shadowShape, state.textShadow.color, shadowBlurPx); + const shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE + shadowBitmap.offsetX; + const shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE + shadowBitmap.offsetY; ctx.save(); ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; - ctx.shadowColor = state.textShadow.color; - ctx.shadowBlur = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; - ctx.shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE; - ctx.shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE; if (!hasTransform) { - ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + ctx.drawImage(shadowBitmap.canvas, drawXEm * DEST_SCALE + shadowOffsetX, drawYEm * DEST_SCALE + shadowOffsetY); } else { applyCharTransformOp(op, hasRotation, hasScale); - ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + ctx.drawImage(shadowBitmap.canvas, drawXEm * DEST_SCALE + shadowOffsetX, drawYEm * DEST_SCALE + shadowOffsetY); } ctx.restore(); } From 7494b6967681aee2281ca67543d253a1a81adac0 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 10:06:33 -0300 Subject: [PATCH 12/21] Big Boy Update: the 2 --- static/extensions/Gen1x/iris-text.js | 1881 +++++++++++++++++++++++--- 1 file changed, 1728 insertions(+), 153 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index bc9deb119..6956b4417 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -145,7 +145,7 @@ Enjoy!! :D ]; const customFonts = []; - document.fonts.forEach(face => { + Array.from(document.fonts).forEach(face => { if (!FONT_IDS.includes(face.family) && !customFonts.some(f => f.value === face.family)) { customFonts.push({ text: face.family, @@ -234,7 +234,7 @@ Enjoy!! :D top.strike = true; break; case 'size': - top.size = Number(value) || top.size; + top.size = Scratch.Cast.toNumber(value) || top.size; break; case 'font': top.font = value === RANDOM_ID ? @@ -356,7 +356,7 @@ Enjoy!! :D textSoFar += text.slice(i, match.index); stages.push({ text: textSoFar, - waitAfter: Math.max(0, Number(match[1]) || 0) + waitAfter: Math.max(0, Scratch.Cast.toNumber(match[1]) || 0) }); i = match.index + match[0].length; } @@ -430,14 +430,21 @@ Enjoy!! :D charBoxesLayout: null, paintOps: null, paintOpsKey: null, + paintOpsGeometryKey: null, paintOpsLayout: null, paintDirty: true, paintFingerprint: null, + renderInFlight: false, + renderQueued: false, + maskBatchCanvas: null, lastFamiliesKey: null, lastFontDefs: '', revealToken: 0, fontsPendingAtMeasure: false, - hasPaintedOnce: false + hasPaintedOnce: false, + taggedIndicesCache: null, + taggedIndicesCacheKey: null, + paintOpPool: [] }; } @@ -676,6 +683,22 @@ Enjoy!! :D const GLYPH_CACHE_LIMIT = 2000; const TINT_CACHE_LIMIT = 512; + const glyphShapeCanvasPool = []; + const tintedCanvasPool = []; + const stencilCanvasPool = []; + const GENERIC_POOL_LIMIT = 256; + + function acquirePooledCanvas(pool, w, h) { + const canvas = pool.pop() || document.createElement('canvas'); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + return canvas; + } + + function releasePooledCanvas(pool, canvas) { + if (canvas && pool.length < GENERIC_POOL_LIMIT) pool.push(canvas); + } + function glyphCacheKey(char, fontFamily, size, weight, style) { return char + '\u0001' + fontFamily + '\u0001' + size + '\u0001' + weight + '\u0001' + style; @@ -706,10 +729,10 @@ Enjoy!! :D const canvasW = leftBearing + rightBearing + pad * 2; const canvasH = ascent + descent + pad * 2; - const canvas = document.createElement('canvas'); - canvas.width = Math.max(1, canvasW); - canvas.height = Math.max(1, canvasH); + const canvas = acquirePooledCanvas(glyphShapeCanvasPool, Math.max(1, canvasW), Math.max(1, canvasH)); const ctx = canvas.getContext('2d'); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = fontStr; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = '#ffffff'; @@ -733,43 +756,109 @@ Enjoy!! :D } const SHADOW_CACHE_LIMIT = 512; + const GLOBAL_SHADOW_CANVAS_LIMIT = 256; + const shadowCanvasPool = []; + const globalShadowLRU = new Map(); + let shadowGlobalKeySeq = 1; + + function acquireShadowCanvas(w, h) { + const reused = shadowCanvasPool.pop(); + const canvas = reused || document.createElement('canvas'); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + return canvas; + } + + function evictOldestGlobalShadow() { + const oldestKey = globalShadowLRU.keys().next().value; + if (oldestKey === undefined) return; + const oldest = globalShadowLRU.get(oldestKey); + globalShadowLRU.delete(oldestKey); + oldest.shape.shadows.delete(oldest.key); + if (shadowCanvasPool.length < 64) shadowCanvasPool.push(oldest.entry.canvas); + } function getShadowGlyphBitmap(shape, color, blurPx) { const key = color + '\u0001' + blurPx; let entry = shape.shadows.get(key); - if (entry) return entry; + if (entry) { + const globalKey = entry.globalKey; + globalShadowLRU.delete(globalKey); + globalShadowLRU.set(globalKey, { + shape, + key, + entry + }); + return entry; + } const padPx = Math.ceil(blurPx * 3) + 2; const w = shape.canvas.width + padPx * 2; const h = shape.canvas.height + padPx * 2; - const canvas = document.createElement('canvas'); - canvas.width = w; - canvas.height = h; + const canvas = acquireShadowCanvas(w, h); const ctx = canvas.getContext('2d'); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, w, h); ctx.shadowColor = color; ctx.shadowBlur = blurPx; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; ctx.drawImage(shape.canvas, padPx, padPx); + const globalKey = shadowGlobalKeySeq++; entry = { canvas, offsetX: -padPx, - offsetY: -padPx + offsetY: -padPx, + globalKey }; if (shape.shadows.size >= SHADOW_CACHE_LIMIT) { - shape.shadows.delete(shape.shadows.keys().next().value); + const evictedKey = shape.shadows.keys().next().value; + const evicted = shape.shadows.get(evictedKey); + shape.shadows.delete(evictedKey); + globalShadowLRU.delete(evicted.globalKey); + releasePooledCanvas(shadowCanvasPool, evicted.canvas); } shape.shadows.set(key, entry); + + if (globalShadowLRU.size >= GLOBAL_SHADOW_CANVAS_LIMIT) evictOldestGlobalShadow(); + globalShadowLRU.set(globalKey, { + shape, + key, + entry + }); return entry; } + function releaseGlyphOwnedCanvases(shape) { + releasePooledCanvas(glyphShapeCanvasPool, shape.canvas); + if (shape.tinted) { + for (const tintedCanvas of shape.tinted.values()) { + releasePooledCanvas(tintedCanvasPool, tintedCanvas); + } + } + if (shape.shadows) { + for (const shadowEntry of shape.shadows.values()) { + globalShadowLRU.delete(shadowEntry.globalKey); + releasePooledCanvas(shadowCanvasPool, shadowEntry.canvas); + } + } + const stencil = maskGlyphStencilCache.get(shape); + if (stencil) { + maskGlyphStencilCache.delete(shape); + releasePooledCanvas(stencilCanvasPool, stencil); + } + } + function getGlyphShape(char, fontFamily, size, weight, style) { const key = glyphCacheKey(char, fontFamily, size, weight, style); let entry = glyphCache.get(key); if (!entry) { if (glyphCache.size >= GLYPH_CACHE_LIMIT) { - glyphCache.delete(glyphCache.keys().next().value); + const evictedKey = glyphCache.keys().next().value; + const evictedShape = glyphCache.get(evictedKey); + glyphCache.delete(evictedKey); + if (evictedShape) releaseGlyphOwnedCanvases(evictedShape); } entry = rasterizeGlyph(char, fontFamily, size, weight, style); glyphCache.set(key, entry); @@ -777,33 +866,42 @@ Enjoy!! :D return entry; } - const maskPatternSurfaces = new Map(); const maskGlyphStencilCache = new Map(); const STENCIL_CACHE_LIMIT = 400; const maskScratchCanvasPool = []; + const CANVAS_SIZE_BUCKET = 32; - function getMaskPatternSurface(texture) { - let surface = maskPatternSurfaces.get(texture.cacheKey); - if (surface) return surface; + function bucketSize(n) { + return Math.max(CANVAS_SIZE_BUCKET, Math.ceil(n / CANVAS_SIZE_BUCKET) * CANVAS_SIZE_BUCKET); + } + + const sharedPatternSurface = (() => { const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; - const ctx = canvas.getContext('2d'); - surface = { canvas, ctx, pattern: null, patternDirty: true }; - maskPatternSurfaces.set(texture.cacheKey, surface); - return surface; - } + return { + canvas, + ctx: canvas.getContext('2d'), + cacheKey: null, + pattern: null, + capW: canvas.width, + capH: canvas.height + }; + })(); function sampleMaskPattern(texture, matrix, w, h) { - const surface = getMaskPatternSurface(texture); - if (surface.canvas.width !== w || surface.canvas.height !== h) { - surface.canvas.width = w; - surface.canvas.height = h; - surface.patternDirty = true; - } - if (surface.patternDirty) { + const surface = sharedPatternSurface; + let patternDirty = surface.cacheKey !== texture.cacheKey; + if (surface.capW < w || surface.capH < h) { + surface.capW = Math.max(surface.capW, bucketSize(w)); + surface.capH = Math.max(surface.capH, bucketSize(h)); + surface.canvas.width = surface.capW; + surface.canvas.height = surface.capH; + patternDirty = true; + } + if (patternDirty) { surface.pattern = surface.ctx.createPattern(texture.canvas, 'repeat'); - surface.patternDirty = false; + surface.cacheKey = texture.cacheKey; } if (!surface.pattern) return null; @@ -821,26 +919,49 @@ Enjoy!! :D function getMaskGlyphStencil(shape) { let stencil = maskGlyphStencilCache.get(shape); if (stencil) return stencil; - stencil = document.createElement('canvas'); - stencil.width = shape.canvas.width; - stencil.height = shape.canvas.height; + stencil = acquirePooledCanvas(stencilCanvasPool, shape.canvas.width, shape.canvas.height); const sctx = stencil.getContext('2d'); + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.globalCompositeOperation = 'source-over'; + sctx.clearRect(0, 0, stencil.width, stencil.height); sctx.fillStyle = '#ffffff'; sctx.fillRect(0, 0, stencil.width, stencil.height); sctx.globalCompositeOperation = 'destination-in'; sctx.drawImage(shape.canvas, 0, 0); if (maskGlyphStencilCache.size >= STENCIL_CACHE_LIMIT) { - maskGlyphStencilCache.delete(maskGlyphStencilCache.keys().next().value); + const evictedShape = maskGlyphStencilCache.keys().next().value; + releasePooledCanvas(stencilCanvasPool, maskGlyphStencilCache.get(evictedShape)); + maskGlyphStencilCache.delete(evictedShape); } maskGlyphStencilCache.set(shape, stencil); return stencil; } function acquireMaskScratchCanvas(w, h) { - const reused = maskScratchCanvasPool.pop(); - const canvas = reused || document.createElement('canvas'); - if (canvas.width !== w) canvas.width = w; - if (canvas.height !== h) canvas.height = h; + let best = -1; + let bestArea = Infinity; + for (let i = 0; i < maskScratchCanvasPool.length; i++) { + const c = maskScratchCanvasPool[i]; + if (c.width >= w && c.height >= h) { + const area = c.width * c.height; + if (area < bestArea) { + bestArea = area; + best = i; + } + } + } + let canvas; + if (best !== -1) { + canvas = maskScratchCanvasPool.splice(best, 1)[0]; + } else { + canvas = maskScratchCanvasPool.length ? maskScratchCanvasPool.pop() : document.createElement('canvas'); + const cw = Math.max(canvas.width, bucketSize(w)); + const ch = Math.max(canvas.height, bucketSize(h)); + if (canvas.width !== cw) canvas.width = cw; + if (canvas.height !== ch) canvas.height = ch; + } + canvas.usedW = w; + canvas.usedH = h; return canvas; } @@ -875,7 +996,7 @@ Enjoy!! :D sctx.clearRect(0, 0, w, h); sctx.globalAlpha = 1; sctx.globalCompositeOperation = 'source-over'; - sctx.drawImage(sampled, 0, 0); + sctx.drawImage(sampled, 0, 0, w, h, 0, 0, w, h); const stencil = getMaskGlyphStencil(shape); sctx.globalCompositeOperation = 'destination-in'; @@ -942,27 +1063,172 @@ Enjoy!! :D destCtx.save(); destCtx.globalAlpha = alpha; - destCtx.drawImage(scratch, destX, destY); + destCtx.drawImage(scratch, 0, 0, w, h, destX, destY, w, h); destCtx.restore(); releaseMaskScratchCanvas(scratch); } + function maskBatchKey(mask, alpha) { + return mask.targetName + '\u0001' + mask.costumeName + '\u0001' + + (mask.direction || WIPE_DIRECTION_BOTTOM_UP) + '\u0001' + + (Number(mask.coverage) || 0) + '\u0001' + + (Number(mask.blur) || 0) + '\u0001' + + (Number(mask.x) || 0) + '\u0001' + + (Number(mask.y) || 0) + '\u0001' + + (Number(mask.zoom) || 100) + '\u0001' + + (Number(mask.rotation) || 0) + '\u0001' + alpha; + } + + function collectBatchedMaskGroups(paintOps) { + const groups = []; + const batchedOps = new Set(); + let group = null; + + const flushGroup = () => { + if (group && group.items.length > 1) { + groups.push(group); + for (let i = 0; i < group.items.length; i++) batchedOps.add(group.items[i].op); + } + group = null; + }; + + for (let i = 0; i < paintOps.length; i++) { + const op = paintOps[i]; + const mask = op.mask; + const maskOpacity = mask ? Math.max(0, Math.min(100, Number(mask.opacity != null ? mask.opacity : 100))) / 100 : 0; + const alpha = (op.opacity == null ? 1 : op.opacity) * maskOpacity; + const canBatch = mask && mask.seamless && !op.rotation && (op.scale == null || op.scale === 1) && + Math.max(0, Math.min(100, Number(mask.coverage) || 0)) > 0 && alpha > 0; + if (!canBatch) { + flushGroup(); + continue; + } + + const key = maskBatchKey(mask, alpha); + if (!group || group.key !== key) { + flushGroup(); + const texture = getMaskTexture(mask.targetName, mask.costumeName); + if (!texture) continue; + group = { + key, + mask, + alpha, + texture, + items: [], + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity + }; + } + + if (op.text === '\u00A0' || op.text === '') continue; + + const fontFamily = op.font['font-family']; + const fontSize = op.font['font-size']; + const fontWeight = op.font['font-weight']; + const fontStyle = op.font['font-style']; + const glyph = getGlyphBitmap(op.text, fontFamily, fontSize, fontWeight, fontStyle, op.color); + const drawXEm = op.x - (glyph.baselineOriginXEm + glyph.advance / 2); + const drawYEm = (op.y + (glyph.fontAscentEm - glyph.fontDescentEm) / 2) - glyph.baselineOriginYEm; + const px = Math.round(drawXEm * DEST_SCALE); + const py = Math.round(drawYEm * DEST_SCALE); + group.items.push({op, glyph, px, py}); + group.minX = Math.min(group.minX, px); + group.minY = Math.min(group.minY, py); + group.maxX = Math.max(group.maxX, px + glyph.canvas.width); + group.maxY = Math.max(group.maxY, py + glyph.canvas.height); + } + flushGroup(); + + return {groups, batchedOps}; + } + + function drawBatchedMaskGroups(ctx, state, groups, pixelW, pixelH) { + if (!groups.length) return; + let canvas = state.maskBatchCanvas; + if (!canvas) { + canvas = document.createElement('canvas'); + state.maskBatchCanvas = canvas; + } + if (canvas.width !== pixelW || canvas.height !== pixelH) { + canvas.width = pixelW; + canvas.height = pixelH; + } + const batchCtx = canvas.getContext('2d'); + + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + const mask = group.mask; + batchCtx.setTransform(1, 0, 0, 1, 0, 0); + batchCtx.globalAlpha = 1; + batchCtx.globalCompositeOperation = 'source-over'; + batchCtx.clearRect(0, 0, pixelW, pixelH); + const pattern = batchCtx.createPattern(group.texture.canvas, 'repeat'); + if (!pattern) continue; + const matrix = new DOMMatrix(); + matrix.translateSelf((Number(mask.x) || 0) * DEST_SCALE, -(Number(mask.y) || 0) * DEST_SCALE); + const rotation = Number(mask.rotation) || 0; + if (rotation !== 0) matrix.rotateSelf(rotation); + const zoom = Math.max(0.01, (Number(mask.zoom) || 100) / 100); + matrix.scaleSelf(zoom, zoom); + pattern.setTransform(matrix); + batchCtx.fillStyle = pattern; + batchCtx.fillRect(0, 0, pixelW, pixelH); + + batchCtx.globalCompositeOperation = 'destination-in'; + for (let j = 0; j < group.items.length; j++) { + const item = group.items[j]; + batchCtx.globalAlpha = group.alpha; + batchCtx.drawImage(item.glyph.shape.canvas, item.px, item.py); + } + batchCtx.globalAlpha = 1; + + const coverage = Math.max(0, Math.min(100, Number(mask.coverage) || 0)) / 100; + if (coverage < 1) { + const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; + const bandHalf = Math.max(0.5, blurPx / 2); + const direction = mask.direction || WIPE_DIRECTION_BOTTOM_UP; + const axisIsX = direction === WIPE_DIRECTION_LEFT_RIGHT || direction === WIPE_DIRECTION_RIGHT_LEFT; + const growsPositive = direction === WIPE_DIRECTION_LEFT_RIGHT || direction === WIPE_DIRECTION_UP_DOWN; + const axisStart = axisIsX ? group.minX : group.minY; + const axisSize = axisIsX ? group.maxX - group.minX : group.maxY - group.minY; + const revealEdge = growsPositive ? axisStart + axisSize * coverage : axisStart + axisSize - axisSize * coverage; + const gradStart = growsPositive ? revealEdge - bandHalf : revealEdge + bandHalf; + const gradEnd = growsPositive ? revealEdge + bandHalf : revealEdge - bandHalf; + const gradient = axisIsX ? + batchCtx.createLinearGradient(gradStart, 0, gradEnd, 0) : + batchCtx.createLinearGradient(0, gradStart, 0, gradEnd); + gradient.addColorStop(0, 'rgba(255,255,255,1)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + batchCtx.fillStyle = gradient; + batchCtx.fillRect(0, 0, pixelW, pixelH); + } + + ctx.drawImage(canvas, 0, 0); + } + } + function tintGlyph(shape, color) { let tintedCanvas = shape.tinted.get(color); if (tintedCanvas) return tintedCanvas; - tintedCanvas = document.createElement('canvas'); - tintedCanvas.width = shape.canvas.width; - tintedCanvas.height = shape.canvas.height; + tintedCanvas = acquirePooledCanvas(tintedCanvasPool, shape.canvas.width, shape.canvas.height); const tctx = tintedCanvas.getContext('2d'); + tctx.setTransform(1, 0, 0, 1, 0, 0); + tctx.globalCompositeOperation = 'source-over'; + tctx.clearRect(0, 0, tintedCanvas.width, tintedCanvas.height); tctx.fillStyle = color; tctx.fillRect(0, 0, tintedCanvas.width, tintedCanvas.height); tctx.globalCompositeOperation = 'destination-in'; tctx.drawImage(shape.canvas, 0, 0); if (shape.tinted.size >= TINT_CACHE_LIMIT) { - shape.tinted.delete(shape.tinted.keys().next().value); + const evictedColor = shape.tinted.keys().next().value; + releasePooledCanvas(tintedCanvasPool, shape.tinted.get(evictedColor)); + shape.tinted.delete(evictedColor); + if (shape.bitmaps) shape.bitmaps.delete(evictedColor); } shape.tinted.set(color, tintedCanvas); return tintedCanvas; @@ -979,7 +1245,8 @@ Enjoy!! :D baselineOriginXEm: shape.baselineOriginXEm, baselineOriginYEm: shape.baselineOriginYEm, fontAscentEm: shape.fontAscentEm, - fontDescentEm: shape.fontDescentEm + fontDescentEm: shape.fontDescentEm, + shape }; if (!shape.bitmaps) shape.bitmaps = new Map(); if (shape.bitmaps.size >= TINT_CACHE_LIMIT) { @@ -989,6 +1256,29 @@ Enjoy!! :D return bitmap; } + const READBACK_CANVAS = document.createElement('canvas'); + const READBACK_CTX = READBACK_CANVAS.getContext('2d', { + willReadFrequently: true + }); + + function getGlyphInkColumns(glyph) { + if (glyph.inkColumns) return glyph.inkColumns; + const w = glyph.canvas.width; + const h = glyph.canvas.height; + if (READBACK_CANVAS.width !== w) READBACK_CANVAS.width = w; + if (READBACK_CANVAS.height !== h) READBACK_CANVAS.height = h; + READBACK_CTX.setTransform(1, 0, 0, 1, 0, 0); + READBACK_CTX.clearRect(0, 0, w, h); + READBACK_CTX.drawImage(glyph.canvas, 0, 0); + const image = READBACK_CTX.getImageData(0, 0, w, h); + const inkColumns = new Uint8Array(w * h); + for (let i = 0; i < inkColumns.length; i++) { + inkColumns[i] = image.data[i * 4 + 3] > 16 ? 1 : 0; + } + glyph.inkColumns = inkColumns; + return inkColumns; + } + function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { const stroke = (x1, x2) => { if (x2 <= x1) return; @@ -999,16 +1289,17 @@ Enjoy!! :D }; try { - const image = glyph.canvas.getContext('2d').getImageData(0, 0, glyph.canvas.width, glyph.canvas.height); + const w = glyph.canvas.width; + const inkData = getGlyphInkColumns(glyph); const rowStart = Math.max(0, Math.floor(y - glyphY - lineWidth / 2)); const rowEnd = Math.min(glyph.canvas.height - 1, Math.ceil(y - glyphY + lineWidth / 2)); const columnStart = Math.max(0, Math.floor(startX - glyphX)); - const columnEnd = Math.min(glyph.canvas.width - 1, Math.ceil(endX - glyphX)); - const ink = new Uint8Array(glyph.canvas.width); + const columnEnd = Math.min(w - 1, Math.ceil(endX - glyphX)); + const ink = new Uint8Array(w); for (let py = rowStart; py <= rowEnd; py++) { for (let px = columnStart; px <= columnEnd; px++) { - if (image.data[(py * glyph.canvas.width + px) * 4 + 3] > 16) ink[px] = 1; + if (inkData[py * w + px]) ink[px] = 1; } } @@ -1034,8 +1325,19 @@ Enjoy!! :D } function invalidateGlyphCacheForFamily(fontFamily) { + const evictedKeys = []; for (const key of glyphCache.keys()) { - if (key.indexOf('\u0001' + fontFamily + '\u0001') !== -1) glyphCache.delete(key); + if (key.indexOf('\u0001' + fontFamily + '\u0001') !== -1) { + glyphCache.delete(key); + evictedKeys.push(key); + } + } + if (evictedKeys.length && renderWorker) { + for (const key of evictedKeys) workerKnownGlyphKeys.delete(key); + renderWorker.postMessage({ + type: 'evict-glyphs', + glyphKeys: evictedKeys + }); } } @@ -1231,18 +1533,33 @@ Enjoy!! :D } } + function shapingFontStyleKey(style) { + return style.font + '\u0001' + style.size + '\u0001' + (style.bold ? 1 : 0) + '\u0001' + (style.italic ? 1 : 0); + } + + const shapingFontStyleCache = new Map(); + const SHAPING_FONT_STYLE_CACHE_LIMIT = 500; + function shapingFontStyle(style) { + const key = shapingFontStyleKey(style); + let cached = shapingFontStyleCache.get(key); + if (cached) return cached; const fontFamily = cssFontFamily(style.font); const fontWeight = style.bold ? 'bold' : 'normal'; const fontStyle = style.italic ? 'italic' : 'normal'; const styleHash = ((hashString(fontFamily) * 33) ^ hashString(fontWeight) * 33 ^ hashString(fontStyle)) | 0; - return { + cached = { 'font-family': fontFamily, 'font-size': style.size, 'font-weight': fontWeight, 'font-style': fontStyle, 'styleHash': styleHash }; + if (shapingFontStyleCache.size >= SHAPING_FONT_STYLE_CACHE_LIMIT) { + shapingFontStyleCache.delete(shapingFontStyleCache.keys().next().value); + } + shapingFontStyleCache.set(key, cached); + return cached; } function canvasFontString(fontStyle) { @@ -1351,6 +1668,7 @@ Enjoy!! :D } } + const hardLineRunsCache = []; let globalIndex = 0; for (const line of hardLines) { const runs = splitShapingRuns(line, state.charOverrides, globalIndex); @@ -1360,6 +1678,7 @@ Enjoy!! :D run.chars[i]._width = run.positions[i].advance; } } + hardLineRunsCache.push(runs); globalIndex += line.length + 1; } @@ -1370,8 +1689,11 @@ Enjoy!! :D let maxWidth = 0; const wrapLimit = state.maxWidth && state.maxWidth > 0 ? state.maxWidth : Infinity; + const wrappedLineSourceHardLine = []; - for (const hardLine of hardLines) { + for (let hardLineIndex = 0; hardLineIndex < hardLines.length; hardLineIndex++) { + const hardLine = hardLines[hardLineIndex]; + const linesBeforeThisHardLine = lines.length; let currentLine = []; let currentLineWidth = 0; @@ -1390,16 +1712,18 @@ Enjoy!! :D if (spaceIdx !== -1 && spaceIdx < currentLine.length - 1) { const nextLineInitial = currentLine.splice(spaceIdx + 1); - currentLineWidth = currentLine.reduce((sum, c) => sum + c._width, 0); + let nextLineWidth = 0; + for (let k = 0; k < nextLineInitial.length; k++) nextLineWidth += nextLineInitial[k]._width; + currentLineWidth -= nextLineWidth; - lines.push([...currentLine]); + lines.push(currentLine); lineWidths.push(currentLineWidth); if (currentLineWidth > maxWidth) maxWidth = currentLineWidth; currentLine = nextLineInitial; - currentLineWidth = currentLine.reduce((sum, c) => sum + c._width, 0); + currentLineWidth = nextLineWidth; } else { - lines.push([...currentLine]); + lines.push(currentLine); lineWidths.push(currentLineWidth); if (currentLineWidth > maxWidth) maxWidth = currentLineWidth; @@ -1420,6 +1744,10 @@ Enjoy!! :D lines.push([]); lineWidths.push(0); } + + if (lines.length - linesBeforeThisHardLine === 1) { + wrappedLineSourceHardLine[linesBeforeThisHardLine] = hardLineIndex; + } } globalIndex = 0; @@ -1428,7 +1756,10 @@ Enjoy!! :D for (let li = 0; li < lines.length; li++) { const line = lines[li]; - const runs = splitShapingRuns(line, state.charOverrides, globalIndex); + const sourceHardLine = wrappedLineSourceHardLine[li]; + const runs = sourceHardLine !== undefined ? + hardLineRunsCache[sourceHardLine] : + splitShapingRuns(line, state.charOverrides, globalIndex); let runStartX = 0; for (const run of runs) { @@ -1486,24 +1817,39 @@ Enjoy!! :D hash = (hash * 33) ^ ((op.letterSpacing * 100) | 0); hash = (hash * 33) ^ (op.underline ? 1 : 0); hash = (hash * 33) ^ (op.strike ? 1 : 0); - if (op.mask) { - const m = op.mask; - hash = (hash * 33) ^ hashString(m.targetName); - hash = (hash * 33) ^ hashString(m.costumeName); - hash = (hash * 33) ^ ((m.coverage * 100) | 0); - hash = (hash * 33) ^ ((m.opacity != null ? m.opacity : 100) * 100 | 0); - hash = (hash * 33) ^ ((m.blur * 100) | 0); - hash = (hash * 33) ^ ((m.x * 100) | 0); - hash = (hash * 33) ^ ((m.y * 100) | 0); - hash = (hash * 33) ^ ((m.zoom != null ? m.zoom : 100) * 100 | 0); - hash = (hash * 33) ^ ((m.rotation != null ? m.rotation : 0) * 100 | 0); - } else { - hash = (hash * 33) ^ 0x5a5a; - } + hash = fingerprintMaskInto(hash, op.mask); + } + return hash | 0; + } + + function fingerprintMaskOps(paintOps, docW, docH) { + let hash = (docW * 2654435761) ^ (docH * 40503); + hash = (hash * 33) ^ paintOps.length; + for (let i = 0; i < paintOps.length; i++) { + hash = (hash * 33) ^ i; + hash = fingerprintMaskInto(hash, paintOps[i].mask); } return hash | 0; } + function fingerprintMaskInto(hash, mask) { + if (mask) { + hash = (hash * 33) ^ hashString(mask.targetName); + hash = (hash * 33) ^ hashString(mask.costumeName); + hash = (hash * 33) ^ ((mask.coverage * 100) | 0); + hash = (hash * 33) ^ ((mask.opacity != null ? mask.opacity : 100) * 100 | 0); + hash = (hash * 33) ^ ((mask.blur * 100) | 0); + hash = (hash * 33) ^ ((mask.x * 100) | 0); + hash = (hash * 33) ^ ((mask.y * 100) | 0); + hash = (hash * 33) ^ ((mask.zoom != null ? mask.zoom : 100) * 100 | 0); + hash = (hash * 33) ^ ((mask.rotation != null ? mask.rotation : 0) * 100 | 0); + } else { + hash = (hash * 33) ^ 0x5a5a; + } + return hash; + } + + function hashString(str) { if (!str) return 0; let h = 0; @@ -1516,8 +1862,49 @@ Enjoy!! :D const pendingRenderTargets = new Set(); const charAnimations = new Map(); + const charAnimationsByTarget = new Map(); let animationTickerInstalled = false; + function addCharAnimation(targetId, key, anim) { + charAnimations.set(key, anim); + let set = charAnimationsByTarget.get(targetId); + if (!set) { + set = new Set(); + charAnimationsByTarget.set(targetId, set); + } + set.add(key); + } + + function deleteCharAnimation(targetId, key) { + if (!charAnimations.delete(key)) return false; + const set = charAnimationsByTarget.get(targetId); + if (set) { + set.delete(key); + if (!set.size) charAnimationsByTarget.delete(targetId); + } + return true; + } + + function clearCharAnimationsForTarget(targetId) { + const set = charAnimationsByTarget.get(targetId); + if (!set || !set.size) return; + for (const key of set) charAnimations.delete(key); + charAnimationsByTarget.delete(targetId); + } + + function clearCharAnimationsForTargetIndex(targetId, idx) { + const set = charAnimationsByTarget.get(targetId); + if (!set || !set.size) return; + const prefix = targetId + '\u0001' + idx + '\u0001'; + for (const key of Array.from(set)) { + if (key.startsWith(prefix)) { + charAnimations.delete(key); + set.delete(key); + } + } + if (!set.size) charAnimationsByTarget.delete(targetId); + } + function nowMs() { return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); } @@ -1661,7 +2048,7 @@ Enjoy!! :D dirtyStates.add(anim.target); if (rawProgress >= 1) { o[anim.property] = anim.to; - charAnimations.delete(key); + deleteCharAnimation(anim.target.id, key); } } for (const target of dirtyStates) { @@ -1706,11 +2093,12 @@ Enjoy!! :D } function flushRenderIfDirty(target) { - if (!pendingRenderTargets.has(target)) return; + if (!pendingRenderTargets.has(target)) return Promise.resolve(); pendingRenderTargets.delete(target); const state = getState(target); - if (state.visible) renderTarget(target); + const promise = state.visible ? renderTarget(target) : Promise.resolve(); if (pendingRenderTargets.size) requestRenderFlush(); + return promise || Promise.resolve(); } function scheduleTextRender(target) { @@ -1727,8 +2115,12 @@ Enjoy!! :D return getShapeKey(state) + '\u0002' + state.charOverridesVersion; } + function getPaintOpsGeometryKey(state, layoutKey) { + return layoutKey + '\u0004' + state.align; + } + function getPaintOpsKey(state, layoutKey) { - return layoutKey + '\u0004' + state.align + '\u0004' + state.charMasksVersion; + return getPaintOpsGeometryKey(state, layoutKey) + '\u0004' + state.charMasksVersion; } const EMPTY_OVERRIDE = { @@ -1756,6 +2148,10 @@ Enjoy!! :D function renderTarget(target) { const state = getState(target); if (!state.visible) return; + if (state.renderInFlight) { + state.renderQueued = true; + return state.renderPromise || Promise.resolve(); + } const shapeKey = getShapeKey(state); const layoutKey = getLayoutKey(state); @@ -1807,16 +2203,38 @@ Enjoy!! :D const originX = docW / 2; const originY = docH / 2; - const paintOpsKey = getPaintOpsKey(state, layoutKey) + '\u0004' + docW + '\u0004' + docH; + const geometryKey = getPaintOpsGeometryKey(state, layoutKey) + '\u0004' + docW + '\u0004' + docH; + const paintOpsKey = geometryKey + '\u0004' + state.charMasksVersion; let paintOps = state.paintOps; let charBoxes = state.charBoxes; - if (state.paintOpsKey !== paintOpsKey || state.paintOpsLayout !== layout || !paintOps) { + const geometryUnchanged = state.paintOpsGeometryKey === geometryKey && + state.paintOpsLayout === layout && !!paintOps; + + if (geometryUnchanged && state.paintOpsKey !== paintOpsKey) { + for (let i = 0; i < paintOps.length; i++) { + const op = paintOps[i]; + op.mask = state.charMasks[op.charIndex] || null; + } + state.paintOpsKey = paintOpsKey; + + const fingerprint = fingerprintMaskOps(paintOps, docW, docH); + if (fingerprint === state.paintFingerprint && state.skinId !== null) { + state.paintDirty = false; + return; + } + state.paintFingerprint = fingerprint; + } else if (state.paintOpsKey !== paintOpsKey || state.paintOpsLayout !== layout || !paintOps) { if (state.charBoxesLayout !== layout) { charBoxes = []; state.charBoxesLayout = layout; } const charsByTag = layout.charsByTag; + const prevPaintOps = paintOps; + const opPool = state.paintOpPool || (state.paintOpPool = []); + if (prevPaintOps) { + for (let i = prevPaintOps.length - 1; i >= 0; i--) opPool.push(prevPaintOps[i]); + } paintOps = []; const letterSpacing = state.letterSpacing || 0; @@ -1870,23 +2288,23 @@ Enjoy!! :D const ch = rc.char === ' ' ? '\u00A0' : rc.char; const mask = state.charMasks[index] || null; - paintOps.push({ - text: ch, - x: charCenterX, - y: charCenterY, - rotation, - opacity, - scale, - color: o.color || rc.color, - font: style, - rawFontFamily: rc0.font, - letterSpacing, - underline, - strike, - width: pos.advance, - mask, - charIndex: index - }); + const op = opPool.length ? opPool.pop() : {}; + op.text = ch; + op.x = charCenterX; + op.y = charCenterY; + op.rotation = rotation; + op.opacity = opacity; + op.scale = scale; + op.color = o.color || rc.color; + op.font = style; + op.rawFontFamily = rc0.font; + op.letterSpacing = letterSpacing; + op.underline = underline; + op.strike = strike; + op.width = pos.advance; + op.mask = mask; + op.charIndex = index; + paintOps.push(op); if (rc.char === ' ') spacesBefore++; @@ -1912,6 +2330,7 @@ Enjoy!! :D state.paintOps = paintOps; state.paintOpsKey = paintOpsKey; + state.paintOpsGeometryKey = geometryKey; state.paintOpsLayout = layout; state.charBoxes = charBoxes; state.charsByTag = charsByTag; @@ -1931,10 +2350,40 @@ Enjoy!! :D } } + state.renderInFlight = true; + state.renderQueued = false; + const paintedPromise = compositeGlyphsAndPush(target, state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); + state.paintDirty = false; + state.renderPromise = Promise.resolve(paintedPromise).then(() => { + state.hasPaintedOnce = true; + }).finally(() => { + state.renderInFlight = false; + state.renderPromise = null; + if (state.renderQueued) { + state.renderQueued = false; + scheduleRender(target); + } + }); + return state.renderPromise; + } + + function compositeGlyphsAndPush(target, state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY) { + if (!renderWorkerFailed && supportsRenderWorker()) { + const workerPromise = compositeGlyphsViaWorker(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); + if (workerPromise) { + return workerPromise.then((result) => { + pushImageBitmapToDrawable(target, result.bitmap, result.pixelW, result.pixelH, docW, docH); + }).catch((err) => { + renderWorkerFailed = true; + console.log('[Iris Text] Render worker request failed (' + (err && err.message ? err.message : err) + ') — falling back to main-thread rendering.'); + const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); + pushCanvasToDrawable(target, canvas, docW, docH); + }); + } + } const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); pushCanvasToDrawable(target, canvas, docW, docH); - state.paintDirty = false; - state.hasPaintedOnce = true; + return Promise.resolve(); } const loadedDocumentFonts = new Set(); @@ -1954,6 +2403,17 @@ Enjoy!! :D } } + let rerenderVisibleTextTargetsQueued = false; + + function requestRerenderVisibleTextTargets() { + if (rerenderVisibleTextTargetsQueued) return; + rerenderVisibleTextTargetsQueued = true; + Promise.resolve().then(() => { + rerenderVisibleTextTargetsQueued = false; + rerenderVisibleTextTargets(); + }); + } + const FONT_PROBE_TEXT = 'AaBbGgQqWwZz01@#'; const FONT_PROBE_CONTROL_FAMILIES = ['serif', 'sans-serif', 'monospace']; let fontProbeCtx = null; @@ -2003,7 +2463,7 @@ Enjoy!! :D if (ready) { loadedDocumentFonts.add(fontId); invalidateGlyphCacheForFamily(cssFontFamily(fontId)); - rerenderVisibleTextTargets(); + requestRerenderVisibleTextTargets(); } else if (attempt < FONT_LOAD_MAX_RETRIES) { setTimeout(() => ensureDocumentFont(fontId, attempt + 1), FONT_LOAD_RETRY_DELAY_MS); } else { @@ -2015,6 +2475,1112 @@ Enjoy!! :D const DEST_SCALE = GLYPH_OVERSAMPLE; + const WORKER_SOURCE = ` +const WIPE_DIRECTION_BOTTOM_UP = 'bottom-up'; +const WIPE_DIRECTION_LEFT_RIGHT = 'left-right'; +const WIPE_DIRECTION_UP_DOWN = 'up-down'; +const WIPE_DIRECTION_RIGHT_LEFT = 'right-left'; + +const DEST_SCALE = 3; +const TINT_CACHE_LIMIT = 512; +const SHADOW_CACHE_LIMIT = 512; +const STENCIL_CACHE_LIMIT = 400; +const CANVAS_SIZE_BUCKET = 32; +const GLYPH_CACHE_LIMIT = 2000; + +const glyphShapesByKey = new Map(); + +const glyphShapeCanvasPool = []; +const tintedCanvasPool = []; +const stencilCanvasPool = []; +const GENERIC_POOL_LIMIT = 256; + +function acquirePooledCanvas(pool, w, h) { + const canvas = pool.pop() || new OffscreenCanvas(w, h); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + return canvas; +} + +function releasePooledCanvas(pool, canvas) { + if (canvas && pool.length < GENERIC_POOL_LIMIT) pool.push(canvas); +} + +function bucketSize(n) { + return Math.max(CANVAS_SIZE_BUCKET, Math.ceil(n / CANVAS_SIZE_BUCKET) * CANVAS_SIZE_BUCKET); +} + +function releaseGlyphOwnedCanvases(shape) { + releasePooledCanvas(glyphShapeCanvasPool, shape.canvas); + if (shape.tinted) { + for (const tintedCanvas of shape.tinted.values()) { + releasePooledCanvas(tintedCanvasPool, tintedCanvas); + } + } + if (shape.shadows) { + for (const shadowEntry of shape.shadows.values()) { + globalShadowLRU.delete(shadowEntry.globalKey); + releasePooledCanvas(shadowCanvasPool, shadowEntry.canvas); + } + } + const stencil = maskGlyphStencilCache.get(shape); + if (stencil) { + maskGlyphStencilCache.delete(shape); + releasePooledCanvas(stencilCanvasPool, stencil); + } +} + +function getGlyphShape(glyphKey) { + return glyphShapesByKey.get(glyphKey) || null; +} + +function tintGlyph(shape, color) { + let tintedCanvas = shape.tinted.get(color); + if (tintedCanvas) return tintedCanvas; + + tintedCanvas = acquirePooledCanvas(tintedCanvasPool, shape.canvas.width, shape.canvas.height); + const tctx = tintedCanvas.getContext('2d'); + tctx.setTransform(1, 0, 0, 1, 0, 0); + tctx.globalCompositeOperation = 'source-over'; + tctx.clearRect(0, 0, tintedCanvas.width, tintedCanvas.height); + tctx.fillStyle = color; + tctx.fillRect(0, 0, tintedCanvas.width, tintedCanvas.height); + tctx.globalCompositeOperation = 'destination-in'; + tctx.drawImage(shape.canvas, 0, 0); + + if (shape.tinted.size >= TINT_CACHE_LIMIT) { + const evictedColor = shape.tinted.keys().next().value; + releasePooledCanvas(tintedCanvasPool, shape.tinted.get(evictedColor)); + shape.tinted.delete(evictedColor); + if (shape.bitmaps) shape.bitmaps.delete(evictedColor); + } + shape.tinted.set(color, tintedCanvas); + return tintedCanvas; +} + +function getGlyphBitmap(glyphKey, color) { + const shape = getGlyphShape(glyphKey); + if (!shape) return null; + let bitmap = shape.bitmaps.get(color); + if (bitmap) return bitmap; + const canvas = tintGlyph(shape, color); + bitmap = { + canvas, + advance: shape.advance, + baselineOriginXEm: shape.baselineOriginXEm, + baselineOriginYEm: shape.baselineOriginYEm, + fontAscentEm: shape.fontAscentEm, + fontDescentEm: shape.fontDescentEm, + shape + }; + if (shape.bitmaps.size >= TINT_CACHE_LIMIT) { + shape.bitmaps.delete(shape.bitmaps.keys().next().value); + } + shape.bitmaps.set(color, bitmap); + return bitmap; +} + +const GLOBAL_SHADOW_CANVAS_LIMIT = 256; +const shadowCanvasPool = []; +const globalShadowLRU = new Map(); +let shadowGlobalKeySeq = 1; + +function acquireShadowCanvas(w, h) { + const reused = shadowCanvasPool.pop(); + const canvas = reused || new OffscreenCanvas(w, h); + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + return canvas; +} + +function evictOldestGlobalShadow() { + const oldestKey = globalShadowLRU.keys().next().value; + if (oldestKey === undefined) return; + const oldest = globalShadowLRU.get(oldestKey); + globalShadowLRU.delete(oldestKey); + oldest.shape.shadows.delete(oldest.key); + if (shadowCanvasPool.length < 64) shadowCanvasPool.push(oldest.entry.canvas); +} + +function getShadowGlyphBitmap(shape, color, blurPx) { + const key = color + '\u0001' + blurPx; + let entry = shape.shadows.get(key); + if (entry) { + const globalKey = entry.globalKey; + globalShadowLRU.delete(globalKey); + globalShadowLRU.set(globalKey, { + shape, + key, + entry + }); + return entry; + } + + const padPx = Math.ceil(blurPx * 3) + 2; + const w = shape.canvas.width + padPx * 2; + const h = shape.canvas.height + padPx * 2; + const canvas = acquireShadowCanvas(w, h); + const ctx = canvas.getContext('2d'); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, w, h); + ctx.shadowColor = color; + ctx.shadowBlur = blurPx; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.drawImage(shape.canvas, padPx, padPx); + + const globalKey = shadowGlobalKeySeq++; + entry = { + canvas, + offsetX: -padPx, + offsetY: -padPx, + globalKey + }; + if (shape.shadows.size >= SHADOW_CACHE_LIMIT) { + const evictedKey = shape.shadows.keys().next().value; + const evicted = shape.shadows.get(evictedKey); + shape.shadows.delete(evictedKey); + globalShadowLRU.delete(evicted.globalKey); + releasePooledCanvas(shadowCanvasPool, evicted.canvas); + } + shape.shadows.set(key, entry); + + if (globalShadowLRU.size >= GLOBAL_SHADOW_CANVAS_LIMIT) evictOldestGlobalShadow(); + globalShadowLRU.set(globalKey, { + shape, + key, + entry + }); + return entry; +} + +const maskGlyphStencilCache = new Map(); +const maskScratchCanvasPool = []; + +const sharedPatternSurface = (() => { + const canvas = new OffscreenCanvas(1, 1); + return { + canvas, + ctx: canvas.getContext('2d'), + cacheKey: null, + pattern: null, + capW: canvas.width, + capH: canvas.height + }; +})(); + +function sampleMaskPattern(texture, matrix, w, h) { + const surface = sharedPatternSurface; + let patternDirty = surface.cacheKey !== texture.cacheKey; + if (surface.capW < w || surface.capH < h) { + surface.capW = Math.max(surface.capW, bucketSize(w)); + surface.capH = Math.max(surface.capH, bucketSize(h)); + surface.canvas.width = surface.capW; + surface.canvas.height = surface.capH; + patternDirty = true; + } + if (patternDirty) { + surface.pattern = surface.ctx.createPattern(texture.canvas, 'repeat'); + surface.cacheKey = texture.cacheKey; + } + if (!surface.pattern) return null; + + surface.pattern.setTransform(matrix); + surface.ctx.setTransform(1, 0, 0, 1, 0, 0); + surface.ctx.globalAlpha = 1; + surface.ctx.globalCompositeOperation = 'source-over'; + surface.ctx.clearRect(0, 0, w, h); + surface.ctx.fillStyle = surface.pattern; + surface.ctx.fillRect(0, 0, w, h); + + return surface.canvas; +} + +function getMaskGlyphStencil(shape) { + let stencil = maskGlyphStencilCache.get(shape); + if (stencil) return stencil; + stencil = acquirePooledCanvas(stencilCanvasPool, shape.canvas.width, shape.canvas.height); + const sctx = stencil.getContext('2d'); + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.globalCompositeOperation = 'source-over'; + sctx.clearRect(0, 0, stencil.width, stencil.height); + sctx.fillStyle = '#ffffff'; + sctx.fillRect(0, 0, stencil.width, stencil.height); + sctx.globalCompositeOperation = 'destination-in'; + sctx.drawImage(shape.canvas, 0, 0); + if (maskGlyphStencilCache.size >= STENCIL_CACHE_LIMIT) { + const evictedShape = maskGlyphStencilCache.keys().next().value; + releasePooledCanvas(stencilCanvasPool, maskGlyphStencilCache.get(evictedShape)); + maskGlyphStencilCache.delete(evictedShape); + } + maskGlyphStencilCache.set(shape, stencil); + return stencil; +} + +function acquireMaskScratchCanvas(w, h) { + let best = -1; + let bestArea = Infinity; + for (let i = 0; i < maskScratchCanvasPool.length; i++) { + const c = maskScratchCanvasPool[i]; + if (c.width >= w && c.height >= h) { + const area = c.width * c.height; + if (area < bestArea) { + bestArea = area; + best = i; + } + } + } + let canvas; + if (best !== -1) { + canvas = maskScratchCanvasPool.splice(best, 1)[0]; + } else { + canvas = maskScratchCanvasPool.length ? maskScratchCanvasPool.pop() : new OffscreenCanvas(1, 1); + const cw = Math.max(canvas.width, bucketSize(w)); + const ch = Math.max(canvas.height, bucketSize(h)); + if (canvas.width !== cw) canvas.width = cw; + if (canvas.height !== ch) canvas.height = ch; + } + return canvas; +} + +function releaseMaskScratchCanvas(canvas) { + if (maskScratchCanvasPool.length < 64) maskScratchCanvasPool.push(canvas); +} + +function drawMaskedGlyph(destCtx, glyphKey, mask, texture, worldDrawX, worldDrawY, spanW, spanH, spanOriginX, spanOriginY, destX, destY, alpha) { + const coverage = Math.max(0, Math.min(100, Number(mask.coverage) || 0)) / 100; + if (coverage <= 0) return; + + const shape = getGlyphShape(glyphKey); + if (!shape) return; + const w = shape.canvas.width; + const h = shape.canvas.height; + + const zoom = Math.max(0.01, (Number(mask.zoom) || 100) / 100); + const rotation = Number(mask.rotation) || 0; + const anchorX = (mask.x || 0) * DEST_SCALE - destX; + const anchorY = -(mask.y || 0) * DEST_SCALE - destY; + + const matrix = new DOMMatrix(); + matrix.translateSelf(anchorX, anchorY); + if (rotation !== 0) matrix.rotateSelf(rotation); + matrix.scaleSelf(zoom, zoom); + + const sampled = sampleMaskPattern(texture, matrix, w, h); + if (!sampled) return; + + const scratch = acquireMaskScratchCanvas(w, h); + const sctx = scratch.getContext('2d'); + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.clearRect(0, 0, w, h); + sctx.globalAlpha = 1; + sctx.globalCompositeOperation = 'source-over'; + sctx.drawImage(sampled, 0, 0, w, h, 0, 0, w, h); + + const stencil = getMaskGlyphStencil(shape); + sctx.globalCompositeOperation = 'destination-in'; + sctx.drawImage(stencil, 0, 0); + sctx.globalCompositeOperation = 'source-over'; + + if (coverage < 1) { + const seamless = !!mask.seamless; + const localOffsetX = seamless ? (worldDrawX - spanOriginX) : 0; + const localOffsetY = seamless ? (worldDrawY - spanOriginY) : 0; + const effSpanW = seamless && spanW ? spanW : w; + const effSpanH = seamless && spanH ? spanH : h; + const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; + const direction = mask.direction || WIPE_DIRECTION_BOTTOM_UP; + const bandHalf = Math.max(0.5, blurPx / 2); + + let revealEdge, axisIsX, growsPositive; + if (direction === WIPE_DIRECTION_LEFT_RIGHT) { + revealEdge = effSpanW * coverage - localOffsetX; + axisIsX = true; + growsPositive = true; + } else if (direction === WIPE_DIRECTION_RIGHT_LEFT) { + revealEdge = effSpanW - effSpanW * coverage - localOffsetX; + axisIsX = true; + growsPositive = false; + } else if (direction === WIPE_DIRECTION_UP_DOWN) { + revealEdge = effSpanH * coverage - localOffsetY; + axisIsX = false; + growsPositive = true; + } else { + revealEdge = effSpanH - effSpanH * coverage - localOffsetY; + axisIsX = false; + growsPositive = false; + } + + const axisSize = axisIsX ? w : h; + const gradStart = growsPositive ? revealEdge - bandHalf : revealEdge + bandHalf; + const gradEnd = growsPositive ? revealEdge + bandHalf : revealEdge - bandHalf; + const bandFrom = Math.min(gradStart, gradEnd); + const bandTo = Math.max(gradStart, gradEnd); + + if (bandTo <= 0) { + if (growsPositive) { + releaseMaskScratchCanvas(scratch); + return; + } + } else if (bandFrom >= axisSize) { + if (!growsPositive) { + releaseMaskScratchCanvas(scratch); + return; + } + } else { + sctx.globalCompositeOperation = 'destination-in'; + const gradient = axisIsX ? + sctx.createLinearGradient(gradStart, 0, gradEnd, 0) : + sctx.createLinearGradient(0, gradStart, 0, gradEnd); + gradient.addColorStop(0, 'rgba(255,255,255,1)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + sctx.fillStyle = gradient; + sctx.fillRect(0, 0, w, h); + sctx.globalCompositeOperation = 'source-over'; + } + } + + destCtx.save(); + destCtx.globalAlpha = alpha; + destCtx.drawImage(scratch, 0, 0, w, h, destX, destY, w, h); + destCtx.restore(); + + releaseMaskScratchCanvas(scratch); +} + +function maskBatchKey(mask, alpha) { + return mask.targetName + '\u0001' + mask.costumeName + '\u0001' + + (mask.direction || WIPE_DIRECTION_BOTTOM_UP) + '\u0001' + + (Number(mask.coverage) || 0) + '\u0001' + + (Number(mask.blur) || 0) + '\u0001' + + (Number(mask.x) || 0) + '\u0001' + + (Number(mask.y) || 0) + '\u0001' + + (Number(mask.zoom) || 100) + '\u0001' + + (Number(mask.rotation) || 0) + '\u0001' + alpha; +} + +function collectBatchedMaskGroups(paintOps, maskTextures) { + const groups = []; + const batchedOps = new Set(); + let group = null; + + const flushGroup = () => { + if (group && group.items.length > 1) { + groups.push(group); + for (let i = 0; i < group.items.length; i++) batchedOps.add(group.items[i].op); + } + group = null; + }; + + for (let i = 0; i < paintOps.length; i++) { + const op = paintOps[i]; + const mask = op.mask; + const maskOpacity = mask ? Math.max(0, Math.min(100, Number(mask.opacity != null ? mask.opacity : 100))) / 100 : 0; + const alpha = (op.opacity == null ? 1 : op.opacity) * maskOpacity; + const canBatch = mask && mask.seamless && !op.rotation && (op.scale == null || op.scale === 1) && + Math.max(0, Math.min(100, Number(mask.coverage) || 0)) > 0 && alpha > 0; + if (!canBatch) { + flushGroup(); + continue; + } + + const key = maskBatchKey(mask, alpha); + if (!group || group.key !== key) { + flushGroup(); + const texture = maskTextures.get(mask.targetName + '\u0001' + mask.costumeName); + if (!texture) continue; + group = { + key, + mask, + alpha, + texture, + items: [], + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity + }; + } + + if (op.text === '\u00A0' || op.text === '') continue; + + const glyph = getGlyphBitmap(op.glyphKey, op.color); + if (!glyph) { + flushGroup(); + continue; + } + const drawXEm = op.x - (glyph.baselineOriginXEm + glyph.advance / 2); + const drawYEm = (op.y + (glyph.fontAscentEm - glyph.fontDescentEm) / 2) - glyph.baselineOriginYEm; + const px = Math.round(drawXEm * DEST_SCALE); + const py = Math.round(drawYEm * DEST_SCALE); + group.items.push({op, glyph, px, py}); + group.minX = Math.min(group.minX, px); + group.minY = Math.min(group.minY, py); + group.maxX = Math.max(group.maxX, px + glyph.canvas.width); + group.maxY = Math.max(group.maxY, py + glyph.canvas.height); + } + flushGroup(); + + return {groups, batchedOps}; +} + +const maskBatchCanvasesByState = new Map(); + +function drawBatchedMaskGroups(ctx, stateId, groups, pixelW, pixelH) { + if (!groups.length) return; + let canvas = maskBatchCanvasesByState.get(stateId); + if (!canvas) { + canvas = new OffscreenCanvas(pixelW, pixelH); + maskBatchCanvasesByState.set(stateId, canvas); + } + if (canvas.width !== pixelW || canvas.height !== pixelH) { + canvas.width = pixelW; + canvas.height = pixelH; + } + const batchCtx = canvas.getContext('2d'); + + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + const mask = group.mask; + batchCtx.setTransform(1, 0, 0, 1, 0, 0); + batchCtx.globalAlpha = 1; + batchCtx.globalCompositeOperation = 'source-over'; + batchCtx.clearRect(0, 0, pixelW, pixelH); + const pattern = batchCtx.createPattern(group.texture.canvas, 'repeat'); + if (!pattern) continue; + const matrix = new DOMMatrix(); + matrix.translateSelf((Number(mask.x) || 0) * DEST_SCALE, -(Number(mask.y) || 0) * DEST_SCALE); + const rotation = Number(mask.rotation) || 0; + if (rotation !== 0) matrix.rotateSelf(rotation); + const zoom = Math.max(0.01, (Number(mask.zoom) || 100) / 100); + matrix.scaleSelf(zoom, zoom); + pattern.setTransform(matrix); + batchCtx.fillStyle = pattern; + batchCtx.fillRect(0, 0, pixelW, pixelH); + + batchCtx.globalCompositeOperation = 'destination-in'; + for (let j = 0; j < group.items.length; j++) { + const item = group.items[j]; + batchCtx.globalAlpha = group.alpha; + batchCtx.drawImage(item.glyph.shape.canvas, item.px, item.py); + } + batchCtx.globalAlpha = 1; + + const coverage = Math.max(0, Math.min(100, Number(mask.coverage) || 0)) / 100; + if (coverage < 1) { + const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; + const bandHalf = Math.max(0.5, blurPx / 2); + const direction = mask.direction || WIPE_DIRECTION_BOTTOM_UP; + const axisIsX = direction === WIPE_DIRECTION_LEFT_RIGHT || direction === WIPE_DIRECTION_RIGHT_LEFT; + const growsPositive = direction === WIPE_DIRECTION_LEFT_RIGHT || direction === WIPE_DIRECTION_UP_DOWN; + const axisStart = axisIsX ? group.minX : group.minY; + const axisSize = axisIsX ? group.maxX - group.minX : group.maxY - group.minY; + const revealEdge = growsPositive ? axisStart + axisSize * coverage : axisStart + axisSize - axisSize * coverage; + const gradStart = growsPositive ? revealEdge - bandHalf : revealEdge + bandHalf; + const gradEnd = growsPositive ? revealEdge + bandHalf : revealEdge - bandHalf; + const gradient = axisIsX ? + batchCtx.createLinearGradient(gradStart, 0, gradEnd, 0) : + batchCtx.createLinearGradient(0, gradStart, 0, gradEnd); + gradient.addColorStop(0, 'rgba(255,255,255,1)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + batchCtx.fillStyle = gradient; + batchCtx.fillRect(0, 0, pixelW, pixelH); + } + + ctx.drawImage(canvas, 0, 0); + } +} + +const READBACK_CANVAS = new OffscreenCanvas(1, 1); +const READBACK_CTX = READBACK_CANVAS.getContext('2d', { + willReadFrequently: true +}); + +function getGlyphInkColumns(glyph) { + if (glyph.inkColumns) return glyph.inkColumns; + const w = glyph.canvas.width; + const h = glyph.canvas.height; + if (READBACK_CANVAS.width !== w) READBACK_CANVAS.width = w; + if (READBACK_CANVAS.height !== h) READBACK_CANVAS.height = h; + READBACK_CTX.setTransform(1, 0, 0, 1, 0, 0); + READBACK_CTX.clearRect(0, 0, w, h); + READBACK_CTX.drawImage(glyph.canvas, 0, 0); + const image = READBACK_CTX.getImageData(0, 0, w, h); + const inkColumns = new Uint8Array(w * h); + for (let i = 0; i < inkColumns.length; i++) { + inkColumns[i] = image.data[i * 4 + 3] > 16 ? 1 : 0; + } + glyph.inkColumns = inkColumns; + return inkColumns; +} + +function strokeUnderline(ctx, glyph, glyphX, glyphY, startX, endX, y, lineWidth) { + const stroke = (x1, x2) => { + if (x2 <= x1) return; + ctx.beginPath(); + ctx.moveTo(x1, y); + ctx.lineTo(x2, y); + ctx.stroke(); + }; + + try { + const w = glyph.canvas.width; + const inkData = getGlyphInkColumns(glyph); + const rowStart = Math.max(0, Math.floor(y - glyphY - lineWidth / 2)); + const rowEnd = Math.min(glyph.canvas.height - 1, Math.ceil(y - glyphY + lineWidth / 2)); + const columnStart = Math.max(0, Math.floor(startX - glyphX)); + const columnEnd = Math.min(w - 1, Math.ceil(endX - glyphX)); + const ink = new Uint8Array(w); + + for (let py = rowStart; py <= rowEnd; py++) { + for (let px = columnStart; px <= columnEnd; px++) { + if (inkData[py * w + px]) ink[px] = 1; + } + } + + const padding = Math.max(1, Math.ceil(lineWidth / 2) + 1); + let segmentStart = startX; + let px = columnStart; + while (px <= columnEnd) { + if (!ink[px]) { + px++; + continue; + } + const inkStart = px; + while (px <= columnEnd && ink[px]) px++; + const skipStart = Math.max(startX, glyphX + inkStart - padding); + const skipEnd = Math.min(endX, glyphX + px + padding); + stroke(segmentStart, skipStart); + segmentStart = Math.max(segmentStart, skipEnd); + } + stroke(segmentStart, endX); + } catch (e) { + stroke(startX, endX); + } +} + +function maskGroupKey(mask) { + return mask.targetName + '\u0001' + mask.costumeName + '\u0001' + + (mask.direction || WIPE_DIRECTION_BOTTOM_UP) + '\u0001' + + Math.round(mask.x * 4) + '\u0001' + Math.round(mask.y * 4); +} + +function computeSeamlessMaskSpansByIndex(paintOps) { + const spans = new Map(); + let groupIndices = null; + let groupKey = null; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + + const flushGroup = () => { + if (!groupIndices || !groupIndices.length) return; + if (!isFinite(minX) || !isFinite(maxX) || !isFinite(minY) || !isFinite(maxY)) return; + const spanW = Math.max(1, maxX - minX); + const spanH = Math.max(1, maxY - minY); + for (const gi of groupIndices) { + spans.set(gi, { + spanW, + spanH, + originX: minX, + originY: minY + }); + } + }; + + for (let i = 0; i < paintOps.length; i++) { + const op = paintOps[i]; + if (!op.mask || !op.mask.seamless) { + flushGroup(); + groupIndices = null; + groupKey = null; + continue; + } + + const key = maskGroupKey(op.mask); + if (groupKey !== key) { + flushGroup(); + groupIndices = []; + groupKey = key; + minX = Infinity; + maxX = -Infinity; + minY = Infinity; + maxY = -Infinity; + } + groupIndices.push(op.charIndex); + + if (op.text === '\u00A0' || op.text === '') continue; + + const shape = getGlyphShape(op.glyphKey); + if (!shape) continue; + const advanceCenterXEm = shape.baselineOriginXEm + shape.advance / 2; + const baselineYFromLineCenter = (shape.fontAscentEm - shape.fontDescentEm) / 2; + const drawXEm = op.x - advanceCenterXEm; + const drawYEm = (op.y + baselineYFromLineCenter) - shape.baselineOriginYEm; + const px = Math.round(drawXEm * DEST_SCALE); + const py = Math.round(drawYEm * DEST_SCALE); + const w = shape.canvas.width; + const h = shape.canvas.height; + + minX = Math.min(minX, px); + maxX = Math.max(maxX, px + w); + minY = Math.min(minY, py); + maxY = Math.max(maxY, py + h); + } + flushGroup(); + + return spans; +} + +const maskSpansCacheByState = new Map(); + +function getSeamlessMaskSpans(stateId, paintOps, layoutDocKey, geometryVersion) { + const cacheKey = layoutDocKey + '\u0003' + geometryVersion; + let entry = maskSpansCacheByState.get(stateId); + if (entry && entry.cacheKey === cacheKey) return entry.spans; + const spans = computeSeamlessMaskSpansByIndex(paintOps); + maskSpansCacheByState.set(stateId, { cacheKey, spans }); + return spans; +} + +const paintCanvasByState = new Map(); + +function compositeGlyphsToCanvas(stateId, settings, paintOps, docW, docH, textWidth, textHeight, originX, originY, maskTextures) { + let canvas = paintCanvasByState.get(stateId); + const pixelW = Math.max(1, Math.round(docW * DEST_SCALE)); + const pixelH = Math.max(1, Math.round(docH * DEST_SCALE)); + if (!canvas) { + canvas = new OffscreenCanvas(pixelW, pixelH); + paintCanvasByState.set(stateId, canvas); + } + if (canvas.width !== pixelW || canvas.height !== pixelH) { + canvas.width = pixelW; + canvas.height = pixelH; + } + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = !!settings.smoothing; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, pixelW, pixelH); + ctx.textBaseline = 'alphabetic'; + + const background = settings.textBackground; + if (background.enabled) { + const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; + const width = textWidth * DEST_SCALE + padding * 2; + const height = textHeight * DEST_SCALE + padding * 2; + const x = originX * DEST_SCALE - width / 2; + const y = originY * DEST_SCALE - height / 2; + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; + ctx.fillStyle = background.color; + const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.fill(); + ctx.restore(); + } + + const maskSpansByIndex = getSeamlessMaskSpans(stateId, paintOps, settings.layoutKey + '\u0003' + docW + '\u0003' + docH, settings.charMaskGeometryVersion); + const batchedMasks = collectBatchedMaskGroups(paintOps, maskTextures); + + const applyCharTransformOp = (op, hasRotation, hasScale) => { + const cx = op.x * DEST_SCALE; + const cy = op.y * DEST_SCALE; + ctx.translate(cx, cy); + if (hasRotation) ctx.rotate(-op.rotation * Math.PI / 180); + if (hasScale) ctx.scale(op.scale, op.scale); + ctx.translate(-cx, -cy); + }; + const resetTransform = () => ctx.setTransform(1, 0, 0, 1, 0, 0); + + for (let i = 0; i < paintOps.length; i++) { + const op = paintOps[i]; + if (op.text === '\u00A0' || op.text === '') continue; + + const glyph = getGlyphBitmap(op.glyphKey, op.color); + if (!glyph) continue; + const fontSize = op.fontSize; + + const advanceCenterXEm = glyph.baselineOriginXEm + glyph.advance / 2; + const baselineYFromLineCenter = (glyph.fontAscentEm - glyph.fontDescentEm) / 2; + const drawXEm = op.x - advanceCenterXEm; + const drawYEm = (op.y + baselineYFromLineCenter) - glyph.baselineOriginYEm; + + const hasOpacity = op.opacity != null && op.opacity !== 1; + const hasRotation = !!op.rotation; + const hasScale = op.scale != null && op.scale !== 1; + const hasTransform = hasRotation || hasScale; + + if (hasTransform) applyCharTransformOp(op, hasRotation, hasScale); + + if (settings.textShadow.enabled) { + const shadowShape = glyph.shape; + const shadowBlurPx = Math.max(0, Number(settings.textShadow.blur) || 0) * DEST_SCALE; + const shadowBitmap = getShadowGlyphBitmap(shadowShape, settings.textShadow.color, shadowBlurPx); + const shadowOffsetX = (Number(settings.textShadow.offsetX) || 0) * DEST_SCALE + shadowBitmap.offsetX; + const shadowOffsetY = (Number(settings.textShadow.offsetY) || 0) * DEST_SCALE + shadowBitmap.offsetY; + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(settings.textShadow.opacity) || 0)) / 100; + ctx.drawImage(shadowBitmap.canvas, drawXEm * DEST_SCALE + shadowOffsetX, drawYEm * DEST_SCALE + shadowOffsetY); + ctx.globalAlpha = 1; + } + + if (op.strike) { + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + ctx.lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + const sy = op.y * DEST_SCALE; + ctx.beginPath(); + ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); + ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); + ctx.stroke(); + if (hasOpacity) ctx.globalAlpha = 1; + } + + if (settings.textBorder.enabled && Number(settings.textBorder.size) > 0) { + const shape = glyph.shape; + const borderGlyph = tintGlyph(shape, settings.textBorder.color); + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(settings.textBorder.opacity) || 0)) / 100; + const borderSize = Math.max(0, Number(settings.textBorder.size) || 0) * DEST_SCALE; + const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; + for (const [offsetX, offsetY] of borderOffsets) { + ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); + } + ctx.globalAlpha = 1; + } + + if (!hasTransform) { + const px = Math.round(drawXEm * DEST_SCALE); + const py = Math.round(drawYEm * DEST_SCALE); + if (hasOpacity) { + ctx.globalAlpha = op.opacity; + ctx.drawImage(glyph.canvas, px, py); + ctx.globalAlpha = 1; + } else { + ctx.drawImage(glyph.canvas, px, py); + } + } else { + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); + if (hasOpacity) ctx.globalAlpha = 1; + } + + if (op.mask && !batchedMasks.batchedOps.has(op)) { + const texture = maskTextures.get(op.mask.targetName + '\u0001' + op.mask.costumeName); + if (texture) { + const px = Math.round(drawXEm * DEST_SCALE); + const py = Math.round(drawYEm * DEST_SCALE); + const span = maskSpansByIndex.get(op.charIndex); + const maskOpacity = Math.max(0, Math.min(100, Number(op.mask.opacity != null ? op.mask.opacity : 100))) / 100; + const combinedAlpha = (hasOpacity ? op.opacity : 1) * maskOpacity; + if (combinedAlpha > 0) { + if (!hasTransform) { + drawMaskedGlyph( + ctx, op.glyphKey, + op.mask, texture, px, py, + span ? span.spanW : 0, span ? span.spanH : 0, + span ? span.originX : 0, span ? span.originY : 0, + px, py, combinedAlpha + ); + } else { + drawMaskedGlyph( + ctx, op.glyphKey, + op.mask, texture, px, py, + span ? span.spanW : 0, span ? span.spanH : 0, + span ? span.originX : 0, span ? span.originY : 0, + drawXEm * DEST_SCALE, drawYEm * DEST_SCALE, combinedAlpha + ); + } + } + } + } + + if (op.underline) { + if (hasOpacity) ctx.globalAlpha = op.opacity; + ctx.strokeStyle = op.color; + const lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; + ctx.lineWidth = lineWidth; + const uy = (op.y + baselineYFromLineCenter + fontSize * 0.06) * DEST_SCALE; + const startX = (op.x - op.width / 2) * DEST_SCALE; + const endX = (op.x + op.width / 2) * DEST_SCALE; + if (hasRotation) { + ctx.beginPath(); + ctx.moveTo(startX, uy); + ctx.lineTo(endX, uy); + ctx.stroke(); + } else { + strokeUnderline(ctx, glyph, Math.round(drawXEm * DEST_SCALE), Math.round(drawYEm * DEST_SCALE), startX, endX, uy, lineWidth); + } + if (hasOpacity) ctx.globalAlpha = 1; + } + + if (hasTransform) resetTransform(); + } + + drawBatchedMaskGroups(ctx, stateId, batchedMasks.groups, pixelW, pixelH); + + return canvas; +} + +const maskTexturesByKey = new Map(); + +self.onmessage = async (event) => { + const msg = event.data; + if (!msg) return; + + if (msg.type === 'composite') { + try { + for (const [key, bitmap] of msg.glyphUpdates) { + const canvas = acquirePooledCanvas(glyphShapeCanvasPool, bitmap.width, bitmap.height); + const gctx = canvas.getContext('2d'); + gctx.setTransform(1, 0, 0, 1, 0, 0); + gctx.clearRect(0, 0, canvas.width, canvas.height); + gctx.drawImage(bitmap, 0, 0); + bitmap.close(); + const meta = msg.glyphMeta.get(key); + if (glyphShapesByKey.size >= GLYPH_CACHE_LIMIT && !glyphShapesByKey.has(key)) { + const evictedKey = glyphShapesByKey.keys().next().value; + const evictedShape = glyphShapesByKey.get(evictedKey); + glyphShapesByKey.delete(evictedKey); + if (evictedShape) releaseGlyphOwnedCanvases(evictedShape); + } + glyphShapesByKey.set(key, { + canvas, + tinted: new Map(), + shadows: new Map(), + bitmaps: new Map(), + advance: meta.advance, + baselineOriginXEm: meta.baselineOriginXEm, + baselineOriginYEm: meta.baselineOriginYEm, + fontAscentEm: meta.fontAscentEm, + fontDescentEm: meta.fontDescentEm + }); + } + + for (const [key, bitmap] of msg.maskTextureUpdates) { + if (bitmap === null) { + maskTexturesByKey.delete(key); + continue; + } + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + canvas.getContext('2d').drawImage(bitmap, 0, 0); + bitmap.close(); + maskTexturesByKey.set(key, { canvas, cacheKey: key }); + } + + const canvas = compositeGlyphsToCanvas( + msg.stateId, msg.settings, msg.paintOps, + msg.docW, msg.docH, msg.textWidth, msg.textHeight, + msg.originX, msg.originY, maskTexturesByKey + ); + + const resultBitmap = canvas.transferToImageBitmap ? + canvas.transferToImageBitmap() : + await createImageBitmap(canvas); + + self.postMessage({ + type: 'composite-result', + requestId: msg.requestId, + stateId: msg.stateId, + bitmap: resultBitmap, + pixelW: canvas.width, + pixelH: canvas.height + }, [resultBitmap]); + } catch (err) { + self.postMessage({ + type: 'composite-error', + requestId: msg.requestId, + stateId: msg.stateId, + message: err && err.message ? err.message : String(err) + }); + } + } else if (msg.type === 'evict-glyphs') { + for (const key of msg.glyphKeys) { + const shape = glyphShapesByKey.get(key); + glyphShapesByKey.delete(key); + if (shape) releaseGlyphOwnedCanvases(shape); + } + } else if (msg.type === 'dispose-state') { + paintCanvasByState.delete(msg.stateId); + maskBatchCanvasesByState.delete(msg.stateId); + maskSpansCacheByState.delete(msg.stateId); + } +}; +`; + + let renderWorker = null; + let renderWorkerFailed = false; + let renderWorkerRequestId = 0; + const renderWorkerPending = new Map(); + const workerKnownGlyphKeys = new Set(); + const workerKnownMaskKeys = new Set(); + const stateWorkerIds = new WeakMap(); + let nextStateWorkerId = 1; + + function supportsRenderWorker() { + return typeof Worker !== 'undefined' && + typeof OffscreenCanvas !== 'undefined' && + typeof createImageBitmap !== 'undefined'; + } + + function getStateWorkerId(state) { + let id = stateWorkerIds.get(state); + if (!id) { + id = nextStateWorkerId++; + stateWorkerIds.set(state, id); + } + return id; + } + + function handleRenderWorkerMessage(event) { + const msg = event.data; + if (!msg) return; + const pending = renderWorkerPending.get(msg.requestId); + if (!pending) return; + renderWorkerPending.delete(msg.requestId); + if (msg.type === 'composite-result') { + pending.resolve({ + bitmap: msg.bitmap, + pixelW: msg.pixelW, + pixelH: msg.pixelH + }); + } else { + pending.reject(new Error(msg.message || 'render worker error')); + } + } + + function ensureRenderWorker() { + if (renderWorker || renderWorkerFailed) return renderWorker; + if (!supportsRenderWorker()) { + renderWorkerFailed = true; + console.log('[Iris Text] SOMEHOW, the render worker is not supported in this environment (wow!). falling back to the main thread.'); + return null; + } + try { + const blob = new Blob([WORKER_SOURCE], { + type: 'text/javascript' + }); + const url = URL.createObjectURL(blob); + renderWorker = new Worker(url); + renderWorker.onmessage = handleRenderWorkerMessage; + renderWorker.onerror = () => { + renderWorkerFailed = true; + console.log('[Iris Text] uh-oh, the render crashed... falling back to the main thread.'); + for (const pending of renderWorkerPending.values()) { + pending.reject(new Error('render worker crashed')); + } + renderWorkerPending.clear(); + if (renderWorker) { + renderWorker.terminate(); + renderWorker = null; + } + }; + console.log('[Iris Text] the render worker has started. using worker rendering.'); + } catch (e) { + renderWorkerFailed = true; + renderWorker = null; + console.log('[Iris Text] render worker failed to start (' + (e && e.message ? e.message : e) + '). using main-thread rendering.'); + } + return renderWorker; + } + + function disposeStateFromWorker(state) { + const id = stateWorkerIds.get(state); + if (id === undefined || !renderWorker) return; + renderWorker.postMessage({ + type: 'dispose-state', + stateId: id + }); + } + + async function compositeGlyphsViaWorker(state, paintOps, docW, docH, textWidth, textHeight, originX, originY) { + const worker = ensureRenderWorker(); + if (!worker) return null; + + const glyphJobs = []; + const neededMaskKeys = new Set(); + + for (let i = 0; i < paintOps.length; i++) { + const op = paintOps[i]; + if (op.text === '\u00A0' || op.text === '') continue; + const fontFamily = op.font['font-family']; + const fontSize = op.font['font-size']; + const fontWeight = op.font['font-weight']; + const fontStyle = op.font['font-style']; + const key = glyphCacheKey(op.text, fontFamily, fontSize, fontWeight, fontStyle); + op.glyphKey = key; + op.fontSize = fontSize; + if (!workerKnownGlyphKeys.has(key) && !glyphJobs.some(j => j.key === key)) { + const shape = getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); + glyphJobs.push({ key, shape }); + workerKnownGlyphKeys.add(key); + } + if (op.mask) { + neededMaskKeys.add(op.mask.targetName + '\u0001' + op.mask.costumeName); + } + } + + const maskJobs = []; + for (const maskKey of neededMaskKeys) { + if (workerKnownMaskKeys.has(maskKey)) continue; + const [targetName, costumeName] = maskKey.split('\u0001'); + const texture = getMaskTexture(targetName, costumeName); + if (!texture) continue; + maskJobs.push({ maskKey, texture }); + workerKnownMaskKeys.add(maskKey); + } + + const [glyphBitmaps, maskBitmaps] = await Promise.all([ + Promise.all(glyphJobs.map(j => createImageBitmap(j.shape.canvas))), + Promise.all(maskJobs.map(j => createImageBitmap(j.texture.canvas))) + ]); + + const glyphUpdates = glyphJobs.map((j, idx) => [j.key, glyphBitmaps[idx]]); + const glyphMeta = glyphJobs.map(j => [j.key, { + advance: j.shape.advance, + baselineOriginXEm: j.shape.baselineOriginXEm, + baselineOriginYEm: j.shape.baselineOriginYEm, + fontAscentEm: j.shape.fontAscentEm, + fontDescentEm: j.shape.fontDescentEm + }]); + const maskTextureUpdates = maskJobs.map((j, idx) => [j.maskKey, maskBitmaps[idx]]); + + const stateId = getStateWorkerId(state); + const requestId = ++renderWorkerRequestId; + + const settings = { + smoothing: state.smoothing, + textBackground: state.textBackground, + textShadow: state.textShadow, + textBorder: state.textBorder, + layoutKey: state.layoutKey, + charMaskGeometryVersion: state.charMaskGeometryVersion + }; + + const transferList = glyphUpdates.map(([, bitmap]) => bitmap) + .concat(maskTextureUpdates.map(([, bitmap]) => bitmap)); + + const resultPromise = new Promise((resolve, reject) => { + renderWorkerPending.set(requestId, { resolve, reject }); + }); + + worker.postMessage({ + type: 'composite', + requestId, + stateId, + settings, + paintOps, + docW, + docH, + textWidth, + textHeight, + originX, + originY, + glyphUpdates, + glyphMeta: new Map(glyphMeta), + maskTextureUpdates + }, transferList); + + return resultPromise; + } + function maskGroupKey(mask) { return mask.targetName + '\u0001' + mask.costumeName + '\u0001' + (mask.direction || WIPE_DIRECTION_BOTTOM_UP) + '\u0001' + @@ -2138,6 +3704,7 @@ Enjoy!! :D } const maskSpansByIndex = getSeamlessMaskSpans(state, paintOps, state.layoutKey + '\u0003' + docW + '\u0003' + docH); + const batchedMasks = collectBatchedMaskGroups(paintOps); const applyCharTransformOp = (op, hasRotation, hasScale) => { const cx = op.x * DEST_SCALE; @@ -2147,6 +3714,7 @@ Enjoy!! :D if (hasScale) ctx.scale(op.scale, op.scale); ctx.translate(-cx, -cy); }; + const resetTransform = () => ctx.setTransform(1, 0, 0, 1, 0, 0); for (let i = 0; i < paintOps.length; i++) { const op = paintOps[i]; @@ -2169,78 +3737,60 @@ Enjoy!! :D const hasScale = op.scale != null && op.scale !== 1; const hasTransform = hasRotation || hasScale; + if (hasTransform) applyCharTransformOp(op, hasRotation, hasScale); + if (state.textShadow.enabled) { - const shadowShape = getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); + const shadowShape = glyph.shape || getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); const shadowBlurPx = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; const shadowBitmap = getShadowGlyphBitmap(shadowShape, state.textShadow.color, shadowBlurPx); const shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE + shadowBitmap.offsetX; const shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE + shadowBitmap.offsetY; - ctx.save(); ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; - if (!hasTransform) { - ctx.drawImage(shadowBitmap.canvas, drawXEm * DEST_SCALE + shadowOffsetX, drawYEm * DEST_SCALE + shadowOffsetY); - } else { - applyCharTransformOp(op, hasRotation, hasScale); - ctx.drawImage(shadowBitmap.canvas, drawXEm * DEST_SCALE + shadowOffsetX, drawYEm * DEST_SCALE + shadowOffsetY); - } - ctx.restore(); + ctx.drawImage(shadowBitmap.canvas, drawXEm * DEST_SCALE + shadowOffsetX, drawYEm * DEST_SCALE + shadowOffsetY); + ctx.globalAlpha = 1; } - if (op.underline || op.strike) { - ctx.save(); + if (op.strike) { if (hasOpacity) ctx.globalAlpha = op.opacity; ctx.strokeStyle = op.color; ctx.lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; - if (op.strike) { - const sy = op.y * DEST_SCALE; - ctx.beginPath(); - ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); - ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); - ctx.stroke(); - } - ctx.restore(); + const sy = op.y * DEST_SCALE; + ctx.beginPath(); + ctx.moveTo((op.x - op.width / 2) * DEST_SCALE, sy); + ctx.lineTo((op.x + op.width / 2) * DEST_SCALE, sy); + ctx.stroke(); + if (hasOpacity) ctx.globalAlpha = 1; } if (state.textBorder.enabled && Number(state.textBorder.size) > 0) { - const shape = getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); + const shape = glyph.shape || getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); const borderGlyph = tintGlyph(shape, state.textBorder.color); - ctx.save(); ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textBorder.opacity) || 0)) / 100; const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; for (const [offsetX, offsetY] of borderOffsets) { - if (!hasTransform) { - ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); - } else { - ctx.save(); - applyCharTransformOp(op, hasRotation, hasScale); - ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); - ctx.restore(); - } + ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); } - ctx.restore(); + ctx.globalAlpha = 1; } if (!hasTransform) { const px = Math.round(drawXEm * DEST_SCALE); const py = Math.round(drawYEm * DEST_SCALE); if (hasOpacity) { - ctx.save(); ctx.globalAlpha = op.opacity; ctx.drawImage(glyph.canvas, px, py); - ctx.restore(); + ctx.globalAlpha = 1; } else { ctx.drawImage(glyph.canvas, px, py); } } else { - ctx.save(); if (hasOpacity) ctx.globalAlpha = op.opacity; - applyCharTransformOp(op, hasRotation, hasScale); ctx.drawImage(glyph.canvas, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE); - ctx.restore(); + if (hasOpacity) ctx.globalAlpha = 1; } - if (op.mask) { + if (op.mask && !batchedMasks.batchedOps.has(op)) { const texture = getMaskTexture(op.mask.targetName, op.mask.costumeName); if (texture) { const px = Math.round(drawXEm * DEST_SCALE); @@ -2258,8 +3808,6 @@ Enjoy!! :D px, py, combinedAlpha ); } else { - ctx.save(); - applyCharTransformOp(op, hasRotation, hasScale); drawMaskedGlyph( ctx, op.text, fontFamily, fontSize, fontWeight, fontStyle, op.mask, texture, px, py, @@ -2267,14 +3815,12 @@ Enjoy!! :D span ? span.originX : 0, span ? span.originY : 0, drawXEm * DEST_SCALE, drawYEm * DEST_SCALE, combinedAlpha ); - ctx.restore(); } } } } if (op.underline) { - ctx.save(); if (hasOpacity) ctx.globalAlpha = op.opacity; ctx.strokeStyle = op.color; const lineWidth = Math.max(1, fontSize * 0.06) * DEST_SCALE; @@ -2290,11 +3836,14 @@ Enjoy!! :D } else { strokeUnderline(ctx, glyph, Math.round(drawXEm * DEST_SCALE), Math.round(drawYEm * DEST_SCALE), startX, endX, uy, lineWidth); } - ctx.restore(); + if (hasOpacity) ctx.globalAlpha = 1; } + if (hasTransform) resetTransform(); } + drawBatchedMaskGroups(ctx, state, batchedMasks.groups, pixelW, pixelH); + return canvas; } @@ -2331,6 +3880,26 @@ Enjoy!! :D runtime.requestRedraw(); } + function pushImageBitmapToDrawable(target, bitmap, pixelW, pixelH, docW, docH) { + const state = getState(target); + let scratch = state.workerResultCanvas; + if (!scratch) { + scratch = document.createElement('canvas'); + scratch.reusable = false; + state.workerResultCanvas = scratch; + } + if (scratch.width !== pixelW || scratch.height !== pixelH) { + scratch.width = pixelW; + scratch.height = pixelH; + } + const sctx = scratch.getContext('2d'); + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.clearRect(0, 0, pixelW, pixelH); + sctx.drawImage(bitmap, 0, 0); + bitmap.close(); + pushCanvasToDrawable(target, scratch, docW, docH); + } + function clearTarget(target) { const state = getState(target); state.visible = false; @@ -2365,11 +3934,22 @@ Enjoy!! :D function taggedCharacterIndices(target, tag) { const state = getState(target); + const shapeKey = getShapeKey(state); + let cache = state.taggedIndicesCache; + if (!cache || state.taggedIndicesCacheKey !== shapeKey) { + cache = new Map(); + state.taggedIndicesCache = cache; + state.taggedIndicesCacheKey = shapeKey; + } + let indices = cache.get(tag); + if (indices) return indices; + const chars = parseRichText(state.rawText, state.baseStyle); - const indices = []; + indices = []; for (let index = 0; index < chars.length; index++) { if (chars[index].tags.includes(tag)) indices.push(index); } + cache.set(tag, indices); return indices; } @@ -2380,6 +3960,7 @@ Enjoy!! :D runtime.on('PROJECT_STOP_ALL', () => { runtime.targets.forEach(t => clearTarget(t)); charAnimations.clear(); + charAnimationsByTarget.clear(); }); runtime.ext_irisText = this; if (typeof runtime.registerCompiledExtensionBlocks === 'function') { @@ -2442,9 +4023,8 @@ Enjoy!! :D runtime.renderer.destroySkin(state.skinId); state.skinId = null; } - for (const key of charAnimations.keys()) { - if (key.startsWith(target.id + '\u0001')) charAnimations.delete(key); - } + if (state) disposeStateFromWorker(state); + clearCharAnimationsForTarget(target.id); } getInfo() { @@ -3766,7 +5346,7 @@ Enjoy!! :D const state = getState(util.target); if (!state.visible) return; if (!state.paintDirty && !pendingRenderTargets.has(util.target)) return; - flushRenderIfDirty(util.target); + return flushRenderIfDirty(util.target); } setBaseColor(args, util) { @@ -4210,13 +5790,13 @@ Enjoy!! :D const key = target.id + '\u0001' + idx + '\u0001' + property; if (duration <= 0) { - charAnimations.delete(key); + deleteCharAnimation(target.id, key); o[property] = targetValue; schedulePaint(target, state); return; } - charAnimations.set(key, { + addCharAnimation(target.id, key, { target, state, index: idx, @@ -4234,10 +5814,7 @@ Enjoy!! :D stopCharAnimations(args, util) { const target = util.target; const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - const prefix = target.id + '\u0001' + idx + '\u0001'; - for (const key of charAnimations.keys()) { - if (key.startsWith(prefix)) charAnimations.delete(key); - } + clearCharAnimationsForTargetIndex(target.id, idx); } resetCharTransform(args, util) { @@ -4254,9 +5831,7 @@ Enjoy!! :D resetAllCharTransforms(args, util) { const state = getState(util.target); const target = util.target; - for (const key of charAnimations.keys()) { - if (key.startsWith(target.id + '\u0001')) charAnimations.delete(key); - } + clearCharAnimationsForTarget(target.id); if (Object.keys(state.charOverrides).length) { state.charOverrides = {}; state.charOverridesVersion++; From 9c12b75326b0ce75c43bc942d694aaf4b5226161 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 10:24:41 -0300 Subject: [PATCH 13/21] address SP's reviews --- static/extensions/Gen1x/iris-text.js | 33 ++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 6956b4417..7b13817aa 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -2084,9 +2084,9 @@ Enjoy!! :D function flushPendingRenders() { if (!pendingRenderTargets.size) return; - const renderTargets = new Set(pendingRenderTargets); + const renderTargets = Array.from(pendingRenderTargets); + pendingRenderTargets.clear(); for (const target of renderTargets) { - pendingRenderTargets.delete(target); const state = getState(target); if (state.visible) renderTarget(target); } @@ -3876,7 +3876,11 @@ self.onmessage = async (event) => { installLinearFiltering(current, state); } } - renderer.updateDrawableSkinId(target.drawableID, state.skinId); + const drawable = renderer._allDrawables[target.drawableID]; + const skin = renderer._allSkins[state.skinId]; + if (!drawable || drawable.skin !== skin) { + renderer.updateDrawableSkinId(target.drawableID, state.skinId); + } runtime.requestRedraw(); } @@ -5226,9 +5230,13 @@ self.onmessage = async (event) => { } const stage = frame.irisStages[frame.irisStageIndex]; - state.rawText = stage.text; - state.paintDirty = true; - scheduleTextRender(util.target); + if (state.rawText !== stage.text) { + state.rawText = stage.text; + state.paintDirty = true; + } + if (state.paintDirty || !state.hasPaintedOnce) { + scheduleTextRender(util.target); + } frame.irisStageIndex++; if (frame.irisStageIndex >= frame.irisStages.length) return; @@ -5431,12 +5439,15 @@ self.onmessage = async (event) => { } applyExportSettings(args, util) { - let settings; - try { - settings = JSON.parse(Scratch.Cast.toString(args.SETTINGS)); - } catch (e) { - return; + let settings = args.SETTINGS; + if (typeof settings === 'string') { + try { + settings = JSON.parse(settings); + } catch (e) { + return; + } } + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return; const sprite = Scratch.Cast.toString(args.SPRITE); const target = sprite === '_myself_' ? util.target : From 2709bb6ef9d954fdb83ad9a52e6c945702832239 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 10:27:48 -0300 Subject: [PATCH 14/21] forgot one :tada: --- static/extensions/Gen1x/iris-text.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 7b13817aa..02fb30876 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -170,6 +170,7 @@ Enjoy!! :D const TAG_RE_STRIP = /\[(\/?)([a-z0-9_-]+)(?:=([^\]]+))?\]/gi; function parseRichText(text, base) { + const families = new Set([base.font]); const stack = [Object.assign({ customTags: [] }, base)]; @@ -192,6 +193,7 @@ Enjoy!! :D font: style.font, tags: style.customTags }); + families.add(style.font); i++; } if (!match) break; @@ -249,6 +251,7 @@ Enjoy!! :D } i = match.index + match[0].length; } + out.families = families; return out; } @@ -285,6 +288,7 @@ Enjoy!! :D } function applyCharacterStyleOverrides(chars, overrides) { + const families = chars.families; for (const index in overrides) { const char = chars[index]; const override = overrides[index]; @@ -296,6 +300,7 @@ Enjoy!! :D char.strike = override.strike; char.size = override.size; char.font = override.font; + if (families) families.add(override.font); } } @@ -2159,7 +2164,7 @@ Enjoy!! :D if (state.shapeKey !== shapeKey || state.layoutKey !== layoutKey || !layout || state.fontsPendingAtMeasure) { const richChars = parseRichText(state.rawText, state.baseStyle); applyCharacterStyleOverrides(richChars, state.charStyleOverrides); - const families = new Set(richChars.map(rc => rc.font)); + const families = richChars.families; let fontsPending = false; for (const family of families) { if (!loadedDocumentFonts.has(family)) fontsPending = true; From b2bfa89c1f5f1b7a2a44e8fd60c732801bd659f3 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 10:35:23 -0300 Subject: [PATCH 15/21] i forgot again --- static/extensions/Gen1x/iris-text.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 02fb30876..8369bd0f7 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -975,7 +975,7 @@ Enjoy!! :D } function drawMaskedGlyph(destCtx, char, fontFamily, size, weight, style, mask, texture, worldDrawX, worldDrawY, spanW, spanH, spanOriginX, spanOriginY, destX, destY, alpha) { - const coverage = Math.max(0, Math.min(100, Number(mask.coverage) || 0)) / 100; + const coverage = Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) / 100; if (coverage <= 0) return; const shape = getGlyphShape(char, fontFamily, size, weight, style); @@ -1077,7 +1077,7 @@ Enjoy!! :D function maskBatchKey(mask, alpha) { return mask.targetName + '\u0001' + mask.costumeName + '\u0001' + (mask.direction || WIPE_DIRECTION_BOTTOM_UP) + '\u0001' + - (Number(mask.coverage) || 0) + '\u0001' + + (Scratch.Cast.toNumber(mask.coverage) || 0) + '\u0001' + (Number(mask.blur) || 0) + '\u0001' + (Number(mask.x) || 0) + '\u0001' + (Number(mask.y) || 0) + '\u0001' + @@ -1104,7 +1104,7 @@ Enjoy!! :D const maskOpacity = mask ? Math.max(0, Math.min(100, Number(mask.opacity != null ? mask.opacity : 100))) / 100 : 0; const alpha = (op.opacity == null ? 1 : op.opacity) * maskOpacity; const canBatch = mask && mask.seamless && !op.rotation && (op.scale == null || op.scale === 1) && - Math.max(0, Math.min(100, Number(mask.coverage) || 0)) > 0 && alpha > 0; + Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) > 0 && alpha > 0; if (!canBatch) { flushGroup(); continue; @@ -1190,7 +1190,7 @@ Enjoy!! :D } batchCtx.globalAlpha = 1; - const coverage = Math.max(0, Math.min(100, Number(mask.coverage) || 0)) / 100; + const coverage = Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) / 100; if (coverage < 1) { const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; const bandHalf = Math.max(0.5, blurPx / 2); @@ -2753,7 +2753,7 @@ function releaseMaskScratchCanvas(canvas) { } function drawMaskedGlyph(destCtx, glyphKey, mask, texture, worldDrawX, worldDrawY, spanW, spanH, spanOriginX, spanOriginY, destX, destY, alpha) { - const coverage = Math.max(0, Math.min(100, Number(mask.coverage) || 0)) / 100; + const coverage = Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) / 100; if (coverage <= 0) return; const shape = getGlyphShape(glyphKey); @@ -2856,7 +2856,7 @@ function drawMaskedGlyph(destCtx, glyphKey, mask, texture, worldDrawX, worldDraw function maskBatchKey(mask, alpha) { return mask.targetName + '\u0001' + mask.costumeName + '\u0001' + (mask.direction || WIPE_DIRECTION_BOTTOM_UP) + '\u0001' + - (Number(mask.coverage) || 0) + '\u0001' + + (Scratch.Cast.toNumber(mask.coverage) || 0) + '\u0001' + (Number(mask.blur) || 0) + '\u0001' + (Number(mask.x) || 0) + '\u0001' + (Number(mask.y) || 0) + '\u0001' + @@ -2883,7 +2883,7 @@ function collectBatchedMaskGroups(paintOps, maskTextures) { const maskOpacity = mask ? Math.max(0, Math.min(100, Number(mask.opacity != null ? mask.opacity : 100))) / 100 : 0; const alpha = (op.opacity == null ? 1 : op.opacity) * maskOpacity; const canBatch = mask && mask.seamless && !op.rotation && (op.scale == null || op.scale === 1) && - Math.max(0, Math.min(100, Number(mask.coverage) || 0)) > 0 && alpha > 0; + Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) > 0 && alpha > 0; if (!canBatch) { flushGroup(); continue; @@ -2971,7 +2971,7 @@ function drawBatchedMaskGroups(ctx, stateId, groups, pixelW, pixelH) { } batchCtx.globalAlpha = 1; - const coverage = Math.max(0, Math.min(100, Number(mask.coverage) || 0)) / 100; + const coverage = Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) / 100; if (coverage < 1) { const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; const bandHalf = Math.max(0.5, blurPx / 2); From 7cad3dfe514d351b3b0d12f200b0593bef365ecc Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 10:46:19 -0300 Subject: [PATCH 16/21] fix some more SP issues --- static/extensions/Gen1x/iris-text.js | 186 ++++++++++++++------------- 1 file changed, 94 insertions(+), 92 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 8369bd0f7..5f7bffcea 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -5,6 +5,8 @@ Code inspiration: - Animated Text (PenguinMod version) - Tween Extension by JeremyGamer13 +Special thanks to SharkPool for helping me find bugs! + Enjoy!! :D */ @@ -982,8 +984,8 @@ Enjoy!! :D const w = shape.canvas.width; const h = shape.canvas.height; - const zoom = Math.max(0.01, (Number(mask.zoom) || 100) / 100); - const rotation = Number(mask.rotation) || 0; + const zoom = Math.max(0.01, (Scratch.Cast.toNumber(mask.zoom) || 100) / 100); + const rotation = Scratch.Cast.toNumber(mask.rotation) || 0; const anchorX = (mask.x || 0) * DEST_SCALE - destX; const anchorY = -(mask.y || 0) * DEST_SCALE - destY; @@ -1014,7 +1016,7 @@ Enjoy!! :D const localOffsetY = seamless ? (worldDrawY - spanOriginY) : 0; const effSpanW = seamless && spanW ? spanW : w; const effSpanH = seamless && spanH ? spanH : h; - const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; + const blurPx = Math.max(0, Scratch.Cast.toNumber(mask.blur) || 0) * DEST_SCALE; const direction = mask.direction || WIPE_DIRECTION_BOTTOM_UP; const bandHalf = Math.max(0.5, blurPx / 2); @@ -1078,11 +1080,11 @@ Enjoy!! :D return mask.targetName + '\u0001' + mask.costumeName + '\u0001' + (mask.direction || WIPE_DIRECTION_BOTTOM_UP) + '\u0001' + (Scratch.Cast.toNumber(mask.coverage) || 0) + '\u0001' + - (Number(mask.blur) || 0) + '\u0001' + - (Number(mask.x) || 0) + '\u0001' + - (Number(mask.y) || 0) + '\u0001' + - (Number(mask.zoom) || 100) + '\u0001' + - (Number(mask.rotation) || 0) + '\u0001' + alpha; + (Scratch.Cast.toNumber(mask.blur) || 0) + '\u0001' + + (Scratch.Cast.toNumber(mask.x) || 0) + '\u0001' + + (Scratch.Cast.toNumber(mask.y) || 0) + '\u0001' + + (Scratch.Cast.toNumber(mask.zoom) || 100) + '\u0001' + + (Scratch.Cast.toNumber(mask.rotation) || 0) + '\u0001' + alpha; } function collectBatchedMaskGroups(paintOps) { @@ -1101,7 +1103,7 @@ Enjoy!! :D for (let i = 0; i < paintOps.length; i++) { const op = paintOps[i]; const mask = op.mask; - const maskOpacity = mask ? Math.max(0, Math.min(100, Number(mask.opacity != null ? mask.opacity : 100))) / 100 : 0; + const maskOpacity = mask ? Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.opacity != null ? mask.opacity : 100))) / 100 : 0; const alpha = (op.opacity == null ? 1 : op.opacity) * maskOpacity; const canBatch = mask && mask.seamless && !op.rotation && (op.scale == null || op.scale === 1) && Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) > 0 && alpha > 0; @@ -1173,10 +1175,10 @@ Enjoy!! :D const pattern = batchCtx.createPattern(group.texture.canvas, 'repeat'); if (!pattern) continue; const matrix = new DOMMatrix(); - matrix.translateSelf((Number(mask.x) || 0) * DEST_SCALE, -(Number(mask.y) || 0) * DEST_SCALE); - const rotation = Number(mask.rotation) || 0; + matrix.translateSelf((Scratch.Cast.toNumber(mask.x) || 0) * DEST_SCALE, -(Scratch.Cast.toNumber(mask.y) || 0) * DEST_SCALE); + const rotation = Scratch.Cast.toNumber(mask.rotation) || 0; if (rotation !== 0) matrix.rotateSelf(rotation); - const zoom = Math.max(0.01, (Number(mask.zoom) || 100) / 100); + const zoom = Math.max(0.01, (Scratch.Cast.toNumber(mask.zoom) || 100) / 100); matrix.scaleSelf(zoom, zoom); pattern.setTransform(matrix); batchCtx.fillStyle = pattern; @@ -1192,7 +1194,7 @@ Enjoy!! :D const coverage = Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) / 100; if (coverage < 1) { - const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; + const blurPx = Math.max(0, Scratch.Cast.toNumber(mask.blur) || 0) * DEST_SCALE; const bandHalf = Math.max(0.5, blurPx / 2); const direction = mask.direction || WIPE_DIRECTION_BOTTOM_UP; const axisIsX = direction === WIPE_DIRECTION_LEFT_RIGHT || direction === WIPE_DIRECTION_RIGHT_LEFT; @@ -1474,24 +1476,9 @@ Enjoy!! :D }); } - const maskRefRetryCounts = new Map(); - const MASK_REF_MAX_RETRIES = 20; - const MASK_REF_RETRY_DELAY_MS = 150; - function getMaskTexture(targetName, costumeName) { - const refKey = targetName + '\u0001' + costumeName; const costume = findCostumeByRef(targetName, costumeName); - if (!costume) { - const attempts = maskRefRetryCounts.get(refKey) || 0; - if (attempts < MASK_REF_MAX_RETRIES) { - maskRefRetryCounts.set(refKey, attempts + 1); - setTimeout(() => { - getMaskTexture(targetName, costumeName); - }, MASK_REF_RETRY_DELAY_MS); - } - return null; - } - maskRefRetryCounts.delete(refKey); + if (!costume) return null; const key = maskCostumeKey(targetName, costume); const cached = maskTextureCache.get(key); if (cached) return cached; @@ -1503,23 +1490,6 @@ Enjoy!! :D result.cacheKey = key; maskTextureCache.set(key, result); repaintAllTargetsUsingMasks(); - } else { - const attempts = maskRefRetryCounts.get(refKey) || 0; - if (attempts < MASK_REF_MAX_RETRIES) { - maskRefRetryCounts.set(refKey, attempts + 1); - setTimeout(() => { - getMaskTexture(targetName, costumeName); - }, MASK_REF_RETRY_DELAY_MS); - } else if (typeof console !== 'undefined' && console.warn) { - const skin = runtime.renderer && runtime.renderer._allSkins && runtime.renderer._allSkins[costume.skinId]; - console.warn('[iris-text] mask texture failed to load after retries:', targetName, costumeName, - 'costume.asset present:', !!costume.asset, - 'dataFormat:', costume.dataFormat, - 'skinId:', costume.skinId, - 'skin found:', !!skin, - 'silhouette lazyData present:', !!(skin && skin._silhouette && skin._silhouette._lazyData), - 'skin keys:', skin ? Object.keys(skin) : null); - } } return result; }); @@ -1887,6 +1857,7 @@ Enjoy!! :D set.delete(key); if (!set.size) charAnimationsByTarget.delete(targetId); } + if (!charAnimations.size) releaseAnimationTicker(); return true; } @@ -1895,6 +1866,7 @@ Enjoy!! :D if (!set || !set.size) return; for (const key of set) charAnimations.delete(key); charAnimationsByTarget.delete(targetId); + if (!charAnimations.size) releaseAnimationTicker(); } function clearCharAnimationsForTargetIndex(targetId, idx) { @@ -1908,6 +1880,7 @@ Enjoy!! :D } } if (!set.size) charAnimationsByTarget.delete(targetId); + if (!charAnimations.size) releaseAnimationTicker(); } function nowMs() { @@ -2031,8 +2004,17 @@ Enjoy!! :D runtime.on('BEFORE_EXECUTE', tickCharAnimations); } + function releaseAnimationTicker() { + if (!animationTickerInstalled) return; + animationTickerInstalled = false; + runtime.removeListener('BEFORE_EXECUTE', tickCharAnimations); + } + function tickCharAnimations() { - if (!charAnimations.size) return; + if (!charAnimations.size) { + releaseAnimationTicker(); + return; + } if (animationsDirtyThisFrame) return; animationsDirtyThisFrame = true; requestAnimationFrame(computeCharAnimationsFrame); @@ -2059,6 +2041,7 @@ Enjoy!! :D for (const target of dirtyStates) { schedulePaint(target, getState(target)); } + if (!charAnimations.size) releaseAnimationTicker(); } let renderFlushLoopRunning = false; @@ -2192,10 +2175,10 @@ Enjoy!! :D const decorationPad = Math.max( 0, - Number(state.textBackground.padding) || 0, - Number(state.textBorder.size) || 0, - (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetX) || 0), - (Number(state.textShadow.blur) || 0) + Math.abs(Number(state.textShadow.offsetY) || 0) + Scratch.Cast.toNumber(state.textBackground.padding) || 0, + Scratch.Cast.toNumber(state.textBorder.size) || 0, + (Scratch.Cast.toNumber(state.textShadow.blur) || 0) + Math.abs(Scratch.Cast.toNumber(state.textShadow.offsetX) || 0), + (Scratch.Cast.toNumber(state.textShadow.blur) || 0) + Math.abs(Scratch.Cast.toNumber(state.textShadow.offsetY) || 0) ); const pad = Math.max(64, state.baseStyle.size * 2, decorationPad + state.baseStyle.size); @@ -2493,6 +2476,16 @@ const STENCIL_CACHE_LIMIT = 400; const CANVAS_SIZE_BUCKET = 32; const GLYPH_CACHE_LIMIT = 2000; +// defined here cuz this is a worker and im not sure if workers can access Scratch +const Scratch = { + Cast: { + toNumber(value) { + const n = Number(value); + return n === n ? n : 0; + } + } +}; + const glyphShapesByKey = new Map(); const glyphShapeCanvasPool = []; @@ -2761,8 +2754,8 @@ function drawMaskedGlyph(destCtx, glyphKey, mask, texture, worldDrawX, worldDraw const w = shape.canvas.width; const h = shape.canvas.height; - const zoom = Math.max(0.01, (Number(mask.zoom) || 100) / 100); - const rotation = Number(mask.rotation) || 0; + const zoom = Math.max(0.01, (Scratch.Cast.toNumber(mask.zoom) || 100) / 100); + const rotation = Scratch.Cast.toNumber(mask.rotation) || 0; const anchorX = (mask.x || 0) * DEST_SCALE - destX; const anchorY = -(mask.y || 0) * DEST_SCALE - destY; @@ -2793,7 +2786,7 @@ function drawMaskedGlyph(destCtx, glyphKey, mask, texture, worldDrawX, worldDraw const localOffsetY = seamless ? (worldDrawY - spanOriginY) : 0; const effSpanW = seamless && spanW ? spanW : w; const effSpanH = seamless && spanH ? spanH : h; - const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; + const blurPx = Math.max(0, Scratch.Cast.toNumber(mask.blur) || 0) * DEST_SCALE; const direction = mask.direction || WIPE_DIRECTION_BOTTOM_UP; const bandHalf = Math.max(0.5, blurPx / 2); @@ -2857,11 +2850,11 @@ function maskBatchKey(mask, alpha) { return mask.targetName + '\u0001' + mask.costumeName + '\u0001' + (mask.direction || WIPE_DIRECTION_BOTTOM_UP) + '\u0001' + (Scratch.Cast.toNumber(mask.coverage) || 0) + '\u0001' + - (Number(mask.blur) || 0) + '\u0001' + - (Number(mask.x) || 0) + '\u0001' + - (Number(mask.y) || 0) + '\u0001' + - (Number(mask.zoom) || 100) + '\u0001' + - (Number(mask.rotation) || 0) + '\u0001' + alpha; + (Scratch.Cast.toNumber(mask.blur) || 0) + '\u0001' + + (Scratch.Cast.toNumber(mask.x) || 0) + '\u0001' + + (Scratch.Cast.toNumber(mask.y) || 0) + '\u0001' + + (Scratch.Cast.toNumber(mask.zoom) || 100) + '\u0001' + + (Scratch.Cast.toNumber(mask.rotation) || 0) + '\u0001' + alpha; } function collectBatchedMaskGroups(paintOps, maskTextures) { @@ -2880,7 +2873,7 @@ function collectBatchedMaskGroups(paintOps, maskTextures) { for (let i = 0; i < paintOps.length; i++) { const op = paintOps[i]; const mask = op.mask; - const maskOpacity = mask ? Math.max(0, Math.min(100, Number(mask.opacity != null ? mask.opacity : 100))) / 100 : 0; + const maskOpacity = mask ? Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.opacity != null ? mask.opacity : 100))) / 100 : 0; const alpha = (op.opacity == null ? 1 : op.opacity) * maskOpacity; const canBatch = mask && mask.seamless && !op.rotation && (op.scale == null || op.scale === 1) && Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) > 0 && alpha > 0; @@ -2954,10 +2947,10 @@ function drawBatchedMaskGroups(ctx, stateId, groups, pixelW, pixelH) { const pattern = batchCtx.createPattern(group.texture.canvas, 'repeat'); if (!pattern) continue; const matrix = new DOMMatrix(); - matrix.translateSelf((Number(mask.x) || 0) * DEST_SCALE, -(Number(mask.y) || 0) * DEST_SCALE); - const rotation = Number(mask.rotation) || 0; + matrix.translateSelf((Scratch.Cast.toNumber(mask.x) || 0) * DEST_SCALE, -(Scratch.Cast.toNumber(mask.y) || 0) * DEST_SCALE); + const rotation = Scratch.Cast.toNumber(mask.rotation) || 0; if (rotation !== 0) matrix.rotateSelf(rotation); - const zoom = Math.max(0.01, (Number(mask.zoom) || 100) / 100); + const zoom = Math.max(0.01, (Scratch.Cast.toNumber(mask.zoom) || 100) / 100); matrix.scaleSelf(zoom, zoom); pattern.setTransform(matrix); batchCtx.fillStyle = pattern; @@ -2973,7 +2966,7 @@ function drawBatchedMaskGroups(ctx, stateId, groups, pixelW, pixelH) { const coverage = Math.max(0, Math.min(100, Scratch.Cast.toNumber(mask.coverage) || 0)) / 100; if (coverage < 1) { - const blurPx = Math.max(0, Number(mask.blur) || 0) * DEST_SCALE; + const blurPx = Math.max(0, Scratch.Cast.toNumber(mask.blur) || 0) * DEST_SCALE; const bandHalf = Math.max(0.5, blurPx / 2); const direction = mask.direction || WIPE_DIRECTION_BOTTOM_UP; const axisIsX = direction === WIPE_DIRECTION_LEFT_RIGHT || direction === WIPE_DIRECTION_RIGHT_LEFT; @@ -3168,15 +3161,15 @@ function compositeGlyphsToCanvas(stateId, settings, paintOps, docW, docH, textWi const background = settings.textBackground; if (background.enabled) { - const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; + const padding = Math.max(0, Scratch.Cast.toNumber(background.padding) || 0) * DEST_SCALE; const width = textWidth * DEST_SCALE + padding * 2; const height = textHeight * DEST_SCALE + padding * 2; const x = originX * DEST_SCALE - width / 2; const y = originY * DEST_SCALE - height / 2; ctx.save(); - ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; + ctx.globalAlpha = Math.max(0, Math.min(100, Scratch.Cast.toNumber(background.opacity) || 0)) / 100; ctx.fillStyle = background.color; - const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); + const radius = Math.min(Math.max(0, Scratch.Cast.toNumber(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); ctx.beginPath(); ctx.roundRect(x, y, width, height, radius); ctx.fill(); @@ -3218,11 +3211,11 @@ function compositeGlyphsToCanvas(stateId, settings, paintOps, docW, docH, textWi if (settings.textShadow.enabled) { const shadowShape = glyph.shape; - const shadowBlurPx = Math.max(0, Number(settings.textShadow.blur) || 0) * DEST_SCALE; + const shadowBlurPx = Math.max(0, Scratch.Cast.toNumber(settings.textShadow.blur) || 0) * DEST_SCALE; const shadowBitmap = getShadowGlyphBitmap(shadowShape, settings.textShadow.color, shadowBlurPx); - const shadowOffsetX = (Number(settings.textShadow.offsetX) || 0) * DEST_SCALE + shadowBitmap.offsetX; - const shadowOffsetY = (Number(settings.textShadow.offsetY) || 0) * DEST_SCALE + shadowBitmap.offsetY; - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(settings.textShadow.opacity) || 0)) / 100; + const shadowOffsetX = (Scratch.Cast.toNumber(settings.textShadow.offsetX) || 0) * DEST_SCALE + shadowBitmap.offsetX; + const shadowOffsetY = (Scratch.Cast.toNumber(settings.textShadow.offsetY) || 0) * DEST_SCALE + shadowBitmap.offsetY; + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Scratch.Cast.toNumber(settings.textShadow.opacity) || 0)) / 100; ctx.drawImage(shadowBitmap.canvas, drawXEm * DEST_SCALE + shadowOffsetX, drawYEm * DEST_SCALE + shadowOffsetY); ctx.globalAlpha = 1; } @@ -3239,11 +3232,11 @@ function compositeGlyphsToCanvas(stateId, settings, paintOps, docW, docH, textWi if (hasOpacity) ctx.globalAlpha = 1; } - if (settings.textBorder.enabled && Number(settings.textBorder.size) > 0) { + if (settings.textBorder.enabled && Scratch.Cast.toNumber(settings.textBorder.size) > 0) { const shape = glyph.shape; const borderGlyph = tintGlyph(shape, settings.textBorder.color); - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(settings.textBorder.opacity) || 0)) / 100; - const borderSize = Math.max(0, Number(settings.textBorder.size) || 0) * DEST_SCALE; + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Scratch.Cast.toNumber(settings.textBorder.opacity) || 0)) / 100; + const borderSize = Math.max(0, Scratch.Cast.toNumber(settings.textBorder.size) || 0) * DEST_SCALE; const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; for (const [offsetX, offsetY] of borderOffsets) { ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); @@ -3273,7 +3266,7 @@ function compositeGlyphsToCanvas(stateId, settings, paintOps, docW, docH, textWi const px = Math.round(drawXEm * DEST_SCALE); const py = Math.round(drawYEm * DEST_SCALE); const span = maskSpansByIndex.get(op.charIndex); - const maskOpacity = Math.max(0, Math.min(100, Number(op.mask.opacity != null ? op.mask.opacity : 100))) / 100; + const maskOpacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(op.mask.opacity != null ? op.mask.opacity : 100))) / 100; const combinedAlpha = (hasOpacity ? op.opacity : 1) * maskOpacity; if (combinedAlpha > 0) { if (!hasTransform) { @@ -3693,15 +3686,15 @@ self.onmessage = async (event) => { const background = state.textBackground; if (background.enabled) { - const padding = Math.max(0, Number(background.padding) || 0) * DEST_SCALE; + const padding = Math.max(0, Scratch.Cast.toNumber(background.padding) || 0) * DEST_SCALE; const width = textWidth * DEST_SCALE + padding * 2; const height = textHeight * DEST_SCALE + padding * 2; const x = originX * DEST_SCALE - width / 2; const y = originY * DEST_SCALE - height / 2; ctx.save(); - ctx.globalAlpha = Math.max(0, Math.min(100, Number(background.opacity) || 0)) / 100; + ctx.globalAlpha = Math.max(0, Math.min(100, Scratch.Cast.toNumber(background.opacity) || 0)) / 100; ctx.fillStyle = background.color; - const radius = Math.min(Math.max(0, Number(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); + const radius = Math.min(Math.max(0, Scratch.Cast.toNumber(background.radius) || 0) * DEST_SCALE, width / 2, height / 2); ctx.beginPath(); ctx.roundRect(x, y, width, height, radius); ctx.fill(); @@ -3746,11 +3739,11 @@ self.onmessage = async (event) => { if (state.textShadow.enabled) { const shadowShape = glyph.shape || getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); - const shadowBlurPx = Math.max(0, Number(state.textShadow.blur) || 0) * DEST_SCALE; + const shadowBlurPx = Math.max(0, Scratch.Cast.toNumber(state.textShadow.blur) || 0) * DEST_SCALE; const shadowBitmap = getShadowGlyphBitmap(shadowShape, state.textShadow.color, shadowBlurPx); - const shadowOffsetX = (Number(state.textShadow.offsetX) || 0) * DEST_SCALE + shadowBitmap.offsetX; - const shadowOffsetY = (Number(state.textShadow.offsetY) || 0) * DEST_SCALE + shadowBitmap.offsetY; - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textShadow.opacity) || 0)) / 100; + const shadowOffsetX = (Scratch.Cast.toNumber(state.textShadow.offsetX) || 0) * DEST_SCALE + shadowBitmap.offsetX; + const shadowOffsetY = (Scratch.Cast.toNumber(state.textShadow.offsetY) || 0) * DEST_SCALE + shadowBitmap.offsetY; + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Scratch.Cast.toNumber(state.textShadow.opacity) || 0)) / 100; ctx.drawImage(shadowBitmap.canvas, drawXEm * DEST_SCALE + shadowOffsetX, drawYEm * DEST_SCALE + shadowOffsetY); ctx.globalAlpha = 1; } @@ -3767,11 +3760,11 @@ self.onmessage = async (event) => { if (hasOpacity) ctx.globalAlpha = 1; } - if (state.textBorder.enabled && Number(state.textBorder.size) > 0) { + if (state.textBorder.enabled && Scratch.Cast.toNumber(state.textBorder.size) > 0) { const shape = glyph.shape || getGlyphShape(op.text, fontFamily, fontSize, fontWeight, fontStyle); const borderGlyph = tintGlyph(shape, state.textBorder.color); - ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Number(state.textBorder.opacity) || 0)) / 100; - const borderSize = Math.max(0, Number(state.textBorder.size) || 0) * DEST_SCALE; + ctx.globalAlpha = (hasOpacity ? op.opacity : 1) * Math.max(0, Math.min(100, Scratch.Cast.toNumber(state.textBorder.opacity) || 0)) / 100; + const borderSize = Math.max(0, Scratch.Cast.toNumber(state.textBorder.size) || 0) * DEST_SCALE; const borderOffsets = [[-borderSize, 0], [borderSize, 0], [0, -borderSize], [0, borderSize], [-borderSize, -borderSize], [borderSize, -borderSize], [-borderSize, borderSize], [borderSize, borderSize]]; for (const [offsetX, offsetY] of borderOffsets) { ctx.drawImage(borderGlyph, drawXEm * DEST_SCALE + offsetX, drawYEm * DEST_SCALE + offsetY); @@ -3801,7 +3794,7 @@ self.onmessage = async (event) => { const px = Math.round(drawXEm * DEST_SCALE); const py = Math.round(drawYEm * DEST_SCALE); const span = maskSpansByIndex.get(op.charIndex); - const maskOpacity = Math.max(0, Math.min(100, Number(op.mask.opacity != null ? op.mask.opacity : 100))) / 100; + const maskOpacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(op.mask.opacity != null ? op.mask.opacity : 100))) / 100; const combinedAlpha = (hasOpacity ? op.opacity : 1) * maskOpacity; if (combinedAlpha > 0) { if (!hasTransform) { @@ -3965,12 +3958,13 @@ self.onmessage = async (event) => { class IrisText { constructor() { this._onTargetRemoved = this._onTargetRemoved.bind(this); + this._onProjectStopAll = this._onProjectStopAll.bind(this); + if (runtime.ext_irisText && runtime.ext_irisText !== this) { + runtime.removeListener('targetWasRemoved', runtime.ext_irisText._onTargetRemoved); + runtime.removeListener('PROJECT_STOP_ALL', runtime.ext_irisText._onProjectStopAll); + } runtime.on('targetWasRemoved', this._onTargetRemoved); - runtime.on('PROJECT_STOP_ALL', () => { - runtime.targets.forEach(t => clearTarget(t)); - charAnimations.clear(); - charAnimationsByTarget.clear(); - }); + runtime.on('PROJECT_STOP_ALL', this._onProjectStopAll); runtime.ext_irisText = this; if (typeof runtime.registerCompiledExtensionBlocks === 'function') { runtime.registerCompiledExtensionBlocks('irisText', this.getCompileInfo()); @@ -4034,6 +4028,14 @@ self.onmessage = async (event) => { } if (state) disposeStateFromWorker(state); clearCharAnimationsForTarget(target.id); + if (!charAnimations.size) releaseAnimationTicker(); + } + + _onProjectStopAll() { + runtime.targets.forEach(t => clearTarget(t)); + charAnimations.clear(); + charAnimationsByTarget.clear(); + releaseAnimationTicker(); } getInfo() { @@ -5322,7 +5324,7 @@ self.onmessage = async (event) => { schedulePaint(util.target, state); startTypingCharacterHat(util.target, step.char, frame.irisTypingCharIndex++); - const delay = Math.max(0, Number(typingDelayForCharacter(state, step.char)) || 0); + const delay = Math.max(0, Scratch.Cast.toNumber(typingDelayForCharacter(state, step.char)) || 0); if (delay > 0) { frame.irisTypingWaitUntil = Date.now() + delay * 1000; util.yieldTick(); From e61755e5ea942904f94485f05a8f94fdbbaa27ae Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 11:04:22 -0300 Subject: [PATCH 17/21] hopefully last issue to fix --- static/extensions/Gen1x/iris-text.js | 94 +++++++++++++++++----------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 5f7bffcea..983252e88 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -764,16 +764,21 @@ Enjoy!! :D const SHADOW_CACHE_LIMIT = 512; const GLOBAL_SHADOW_CANVAS_LIMIT = 256; - const shadowCanvasPool = []; const globalShadowLRU = new Map(); let shadowGlobalKeySeq = 1; - function acquireShadowCanvas(w, h) { - const reused = shadowCanvasPool.pop(); - const canvas = reused || document.createElement('canvas'); - if (canvas.width !== w) canvas.width = w; - if (canvas.height !== h) canvas.height = h; - return canvas; + const sharedShadowSurface = (() => { + const canvas = typeof OffscreenCanvas === 'function' ? new OffscreenCanvas(1, 1) : document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + return { + canvas, + ctx: canvas.getContext('2d') + }; + })(); + + function releaseShadowCapture(capture) { + if (capture && capture !== sharedShadowSurface.canvas && typeof capture.close === 'function') capture.close(); } function evictOldestGlobalShadow() { @@ -782,7 +787,7 @@ Enjoy!! :D const oldest = globalShadowLRU.get(oldestKey); globalShadowLRU.delete(oldestKey); oldest.shape.shadows.delete(oldest.key); - if (shadowCanvasPool.length < 64) shadowCanvasPool.push(oldest.entry.canvas); + releaseShadowCapture(oldest.entry.canvas); } function getShadowGlyphBitmap(shape, color, blurPx) { @@ -802,8 +807,11 @@ Enjoy!! :D const padPx = Math.ceil(blurPx * 3) + 2; const w = shape.canvas.width + padPx * 2; const h = shape.canvas.height + padPx * 2; - const canvas = acquireShadowCanvas(w, h); - const ctx = canvas.getContext('2d'); + const surface = sharedShadowSurface; + if (surface.canvas.width !== w) surface.canvas.width = w; + if (surface.canvas.height !== h) surface.canvas.height = h; + const ctx = surface.canvas.getContext('2d'); + surface.ctx = ctx; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h); ctx.shadowColor = color; @@ -812,19 +820,23 @@ Enjoy!! :D ctx.shadowOffsetY = 0; ctx.drawImage(shape.canvas, padPx, padPx); - const globalKey = shadowGlobalKeySeq++; + const canSnapshot = typeof surface.canvas.transferToImageBitmap === 'function'; + const canvas = canSnapshot ? surface.canvas.transferToImageBitmap() : surface.canvas; entry = { canvas, offsetX: -padPx, - offsetY: -padPx, - globalKey + offsetY: -padPx }; + if (!canSnapshot) return entry; + + const globalKey = shadowGlobalKeySeq++; + entry.globalKey = globalKey; if (shape.shadows.size >= SHADOW_CACHE_LIMIT) { const evictedKey = shape.shadows.keys().next().value; const evicted = shape.shadows.get(evictedKey); shape.shadows.delete(evictedKey); globalShadowLRU.delete(evicted.globalKey); - releasePooledCanvas(shadowCanvasPool, evicted.canvas); + releaseShadowCapture(evicted.canvas); } shape.shadows.set(key, entry); @@ -847,7 +859,7 @@ Enjoy!! :D if (shape.shadows) { for (const shadowEntry of shape.shadows.values()) { globalShadowLRU.delete(shadowEntry.globalKey); - releasePooledCanvas(shadowCanvasPool, shadowEntry.canvas); + releaseShadowCapture(shadowEntry.canvas); } } const stencil = maskGlyphStencilCache.get(shape); @@ -2508,6 +2520,22 @@ function bucketSize(n) { return Math.max(CANVAS_SIZE_BUCKET, Math.ceil(n / CANVAS_SIZE_BUCKET) * CANVAS_SIZE_BUCKET); } +const GLOBAL_SHADOW_CANVAS_LIMIT = 256; +const globalShadowLRU = new Map(); +let shadowGlobalKeySeq = 1; + +const sharedShadowSurface = (() => { + const canvas = new OffscreenCanvas(1, 1); + return { + canvas, + ctx: canvas.getContext('2d') + }; +})(); + +function releaseShadowCapture(capture) { + if (capture && capture !== sharedShadowSurface.canvas && typeof capture.close === 'function') capture.close(); +} + function releaseGlyphOwnedCanvases(shape) { releasePooledCanvas(glyphShapeCanvasPool, shape.canvas); if (shape.tinted) { @@ -2518,7 +2546,7 @@ function releaseGlyphOwnedCanvases(shape) { if (shape.shadows) { for (const shadowEntry of shape.shadows.values()) { globalShadowLRU.delete(shadowEntry.globalKey); - releasePooledCanvas(shadowCanvasPool, shadowEntry.canvas); + releaseShadowCapture(shadowEntry.canvas); } } const stencil = maskGlyphStencilCache.get(shape); @@ -2578,26 +2606,13 @@ function getGlyphBitmap(glyphKey, color) { return bitmap; } -const GLOBAL_SHADOW_CANVAS_LIMIT = 256; -const shadowCanvasPool = []; -const globalShadowLRU = new Map(); -let shadowGlobalKeySeq = 1; - -function acquireShadowCanvas(w, h) { - const reused = shadowCanvasPool.pop(); - const canvas = reused || new OffscreenCanvas(w, h); - if (canvas.width !== w) canvas.width = w; - if (canvas.height !== h) canvas.height = h; - return canvas; -} - function evictOldestGlobalShadow() { const oldestKey = globalShadowLRU.keys().next().value; if (oldestKey === undefined) return; const oldest = globalShadowLRU.get(oldestKey); globalShadowLRU.delete(oldestKey); oldest.shape.shadows.delete(oldest.key); - if (shadowCanvasPool.length < 64) shadowCanvasPool.push(oldest.entry.canvas); + releaseShadowCapture(oldest.entry.canvas); } function getShadowGlyphBitmap(shape, color, blurPx) { @@ -2617,8 +2632,11 @@ function getShadowGlyphBitmap(shape, color, blurPx) { const padPx = Math.ceil(blurPx * 3) + 2; const w = shape.canvas.width + padPx * 2; const h = shape.canvas.height + padPx * 2; - const canvas = acquireShadowCanvas(w, h); - const ctx = canvas.getContext('2d'); + const surface = sharedShadowSurface; + if (surface.canvas.width !== w) surface.canvas.width = w; + if (surface.canvas.height !== h) surface.canvas.height = h; + const ctx = surface.canvas.getContext('2d'); + surface.ctx = ctx; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h); ctx.shadowColor = color; @@ -2627,19 +2645,23 @@ function getShadowGlyphBitmap(shape, color, blurPx) { ctx.shadowOffsetY = 0; ctx.drawImage(shape.canvas, padPx, padPx); - const globalKey = shadowGlobalKeySeq++; + const canSnapshot = typeof surface.canvas.transferToImageBitmap === 'function'; + const canvas = canSnapshot ? surface.canvas.transferToImageBitmap() : surface.canvas; entry = { canvas, offsetX: -padPx, - offsetY: -padPx, - globalKey + offsetY: -padPx }; + if (!canSnapshot) return entry; + + const globalKey = shadowGlobalKeySeq++; + entry.globalKey = globalKey; if (shape.shadows.size >= SHADOW_CACHE_LIMIT) { const evictedKey = shape.shadows.keys().next().value; const evicted = shape.shadows.get(evictedKey); shape.shadows.delete(evictedKey); globalShadowLRU.delete(evicted.globalKey); - releasePooledCanvas(shadowCanvasPool, evicted.canvas); + releaseShadowCapture(evicted.canvas); } shape.shadows.set(key, entry); From a59ac1f889a6ffff7e5ca0502c3306ed13a5630f Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 11:49:02 -0300 Subject: [PATCH 18/21] fix an alignment bug --- static/extensions/Gen1x/iris-text.js | 72 +++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 983252e88..517440bb9 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -451,7 +451,11 @@ Enjoy!! :D hasPaintedOnce: false, taggedIndicesCache: null, taggedIndicesCacheKey: null, - paintOpPool: [] + paintOpPool: [], + typingFullRaw: null, + typingBoxKey: null, + typingBoxWidth: 0, + typingBoxHeight: 0 }; } @@ -1787,8 +1791,37 @@ Enjoy!! :D }; } - function fingerprintPaintOps(paintOps, docW, docH) { + function clearTypingBox(state) { + state.typingFullRaw = null; + state.typingBoxKey = null; + state.typingBoxWidth = 0; + state.typingBoxHeight = 0; + } + + function shapeKeyForText(state, rawText) { + const prev = state.rawText; + state.rawText = rawText; + const key = getShapeKey(state); + state.rawText = prev; + return key; + } + + function ensureTypingBox(state) { + if (!state.typingFullRaw) return; + const key = shapeKeyForText(state, state.typingFullRaw); + if (state.typingBoxKey === key) return; + const richChars = parseRichText(state.typingFullRaw, state.baseStyle); + applyCharacterStyleOverrides(richChars, state.charStyleOverrides); + const measured = measureRichText(glyphMeasureCtx, richChars, state); + const lineHeight = state.baseStyle.size * state.lineSpacing; + state.typingBoxKey = key; + state.typingBoxWidth = measured.maxWidth; + state.typingBoxHeight = measured.lines.length * lineHeight; + } + + function fingerprintPaintOps(paintOps, docW, docH, align) { let hash = (docW * 2654435761) ^ (docH * 40503); + hash = (hash * 33) ^ hashString(align); hash = (hash * 33) ^ paintOps.length; for (let i = 0; i < paintOps.length; i++) { const op = paintOps[i]; @@ -2194,8 +2227,11 @@ Enjoy!! :D ); const pad = Math.max(64, state.baseStyle.size * 2, decorationPad + state.baseStyle.size); - const effectiveMaxWidth = state.maxWidth && state.maxWidth > 0 ? state.maxWidth : maxWidth; - const effectiveMaxHeight = state.maxHeight && state.maxHeight > 0 ? state.maxHeight : totalHeight; + ensureTypingBox(state); + let effectiveMaxWidth = state.maxWidth && state.maxWidth > 0 ? state.maxWidth : maxWidth; + let effectiveMaxHeight = state.maxHeight && state.maxHeight > 0 ? state.maxHeight : totalHeight; + if (state.typingBoxWidth > effectiveMaxWidth) effectiveMaxWidth = state.typingBoxWidth; + if (state.typingBoxHeight > effectiveMaxHeight) effectiveMaxHeight = state.typingBoxHeight; const docW = Math.ceil(effectiveMaxWidth + pad * 2); const docH = Math.ceil(effectiveMaxHeight + pad * 2); @@ -2337,7 +2373,7 @@ Enjoy!! :D state.drawableWidth = docW; state.drawableHeight = docH; - const fingerprint = fingerprintPaintOps(paintOps, docW, docH); + const fingerprint = fingerprintPaintOps(paintOps, docW, docH, state.align); if (fingerprint === state.paintFingerprint && state.skinId !== null) { state.paintDirty = false; return; @@ -3928,6 +3964,7 @@ self.onmessage = async (event) => { const state = getState(target); state.visible = false; state.revealToken++; + clearTypingBox(state); state.paintDirty = true; state.paintFingerprint = null; const costume = target.getCostumes()[target.currentCostume]; @@ -4083,7 +4120,7 @@ self.onmessage = async (event) => { } } }, - { + { opcode: 'addLine', blockType: Scratch.BlockType.COMMAND, text: 'add line [TEXT]', @@ -5238,11 +5275,12 @@ self.onmessage = async (event) => { const frame = util.stackFrame; if (frame.irisStages === undefined) { - state.rawText = Scratch.Cast.toString(args.TEXT); - state.finalText = stripMarkup(state.rawText); + const text = Scratch.Cast.toString(args.TEXT); + state.finalText = stripMarkup(text); state.visible = true; if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); - frame.irisStages = splitWaitStages(state.rawText); + clearTypingBox(state); + frame.irisStages = splitWaitStages(text); frame.irisStageIndex = 0; frame.irisToken = ++state.revealToken; frame.irisWaitUntil = null; @@ -5273,8 +5311,8 @@ self.onmessage = async (event) => { frame.irisWaitUntil = Date.now() + stage.waitAfter * 1000; util.yieldTick(); } - - addLine(args, util) { + + addLine(args, util) { const state = getState(util.target); const text = Scratch.Cast.toString(args.TEXT); const existingText = state.visible ? state.rawText : ''; @@ -5282,6 +5320,7 @@ self.onmessage = async (event) => { if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); state.revealToken++; state.typingControl = null; + clearTypingBox(state); state.rawText = existingText + linePrefix + text; state.finalText = stripMarkup(state.rawText); state.visible = true; @@ -5297,11 +5336,13 @@ self.onmessage = async (event) => { const existingText = appendLine && state.visible ? state.rawText : ''; const linePrefix = appendLine && existingText ? '\n' : ''; state.rawText = existingText + linePrefix; - state.finalText = stripMarkup(state.rawText + text); + state.typingFullRaw = state.rawText + text; + state.finalText = stripMarkup(state.typingFullRaw); state.visible = true; if (state.resetCharTransformsOnText) resetCharacterTransformsState(state); state.revealToken++; state.typingControl = null; + state.typingBoxKey = null; frame.irisTypingSteps = splitTypingSteps(text); frame.irisTypingStepIndex = 0; frame.irisTypingCharIndex = stripMarkup(existingText + linePrefix).length; @@ -5315,6 +5356,8 @@ self.onmessage = async (event) => { if (state.typingControl === 'stop') { state.typingControl = null; state.finalText = stripMarkup(state.rawText); + clearTypingBox(state); + schedulePaint(util.target, state); return; } @@ -5324,6 +5367,7 @@ self.onmessage = async (event) => { } state.rawText = frame.irisTypedText; state.typingControl = null; + clearTypingBox(state); schedulePaint(util.target, state); startTypingFinishedHat(util.target); return; @@ -5355,6 +5399,7 @@ self.onmessage = async (event) => { } state.rawText = frame.irisTypedText; + clearTypingBox(state); schedulePaint(util.target, state); startTypingFinishedHat(util.target); } @@ -5487,7 +5532,8 @@ self.onmessage = async (event) => { setAlign(args, util) { const state = getState(util.target); - const align = Scratch.Cast.toString(args.ALIGN); + const align = Scratch.Cast.toString(args.ALIGN).toLowerCase(); + if (align !== 'left' && align !== 'center' && align !== 'right' && align !== 'justify') return; if (state.align === align) return; state.align = align; schedulePaint(util.target, state); From f7e66c61aeeb8331e0f4914fa37cbcdefe112f76 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 12:09:47 -0300 Subject: [PATCH 19/21] fix the animation block --- static/extensions/Gen1x/iris-text.js | 73 +++++++++++++++++++--------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 517440bb9..da6f2a166 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -419,6 +419,7 @@ Enjoy!! :D }, charOverrides: {}, charOverridesVersion: 0, + charTransformsVersion: 0, resetCharTransformsOnText: true, charStyleOverrides: {}, charStyleOverridesVersion: 0, @@ -438,6 +439,8 @@ Enjoy!! :D paintOps: null, paintOpsKey: null, paintOpsGeometryKey: null, + paintOpsTransformsVersion: 0, + paintOpsMasksVersion: 0, paintOpsLayout: null, paintDirty: true, paintFingerprint: null, @@ -494,6 +497,9 @@ Enjoy!! :D state.charStyleOverrides = Object.assign({}, state.charStyleOverrides || {}); if (typeof state.resetCharTransformsOnText !== 'boolean') state.resetCharTransformsOnText = defaults.resetCharTransformsOnText; if (typeof state.charStyleOverridesVersion !== 'number') state.charStyleOverridesVersion = 0; + if (typeof state.charTransformsVersion !== 'number') state.charTransformsVersion = 0; + if (typeof state.paintOpsTransformsVersion !== 'number') state.paintOpsTransformsVersion = 0; + if (typeof state.paintOpsMasksVersion !== 'number') state.paintOpsMasksVersion = 0; if (state.typingControl !== 'skip' && state.typingControl !== 'stop') state.typingControl = null; state.iiMigrated = true; } @@ -642,6 +648,7 @@ Enjoy!! :D color: null }; state.charOverridesVersion++; + state.charTransformsVersion++; } return state.charOverrides[index]; } @@ -650,6 +657,7 @@ Enjoy!! :D if (!Object.keys(state.charOverrides).length) return; state.charOverrides = {}; state.charOverridesVersion++; + state.charTransformsVersion++; } function setCharMaskForIndex(state, index, mask) { @@ -2038,45 +2046,47 @@ Enjoy!! :D function applyEasing(name, dir, x) { const fn = EasingMethods[name] || linear; if (fn === linear) return linear(x); - return fn(x, dir); + const d = (dir || 'out').toString().toLowerCase().trim(); + const normalizedDir = (d === 'in' || d === 'out' || d === 'in out') ? d : 'out'; + const res = fn(x, normalizedDir); + return Number.isFinite(res) ? res : x; } - let animationsDirtyThisFrame = false; + let animationFrameId = null; function ensureAnimationTicker() { - if (animationTickerInstalled) return; - animationTickerInstalled = true; - runtime.on('BEFORE_EXECUTE', tickCharAnimations); + if (animationFrameId !== null || !charAnimations.size) return; + animationFrameId = requestAnimationFrame(stepCharAnimations); } function releaseAnimationTicker() { - if (!animationTickerInstalled) return; - animationTickerInstalled = false; - runtime.removeListener('BEFORE_EXECUTE', tickCharAnimations); + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } } - function tickCharAnimations() { - if (!charAnimations.size) { - releaseAnimationTicker(); - return; + function stepCharAnimations() { + animationFrameId = null; + if (!charAnimations.size) return; + computeCharAnimationsFrame(); + if (charAnimations.size && animationFrameId === null) { + animationFrameId = requestAnimationFrame(stepCharAnimations); } - if (animationsDirtyThisFrame) return; - animationsDirtyThisFrame = true; - requestAnimationFrame(computeCharAnimationsFrame); } function computeCharAnimationsFrame() { - animationsDirtyThisFrame = false; if (!charAnimations.size) return; const t = nowMs(); const dirtyStates = new Set(); - for (const [key, anim] of charAnimations) { + for (const [key, anim] of Array.from(charAnimations.entries())) { const state = getState(anim.target); const o = getCharOverride(state, anim.index); const elapsed = t - anim.startTime; const rawProgress = anim.duration > 0 ? Math.min(1, elapsed / anim.duration) : 1; const progress = applyEasing(anim.easing, anim.direction, rawProgress); o[anim.property] = anim.from + (anim.to - anim.from) * progress; + state.charTransformsVersion++; dirtyStates.add(anim.target); if (rawProgress >= 1) { o[anim.property] = anim.to; @@ -2086,6 +2096,7 @@ Enjoy!! :D for (const target of dirtyStates) { schedulePaint(target, getState(target)); } + flushPendingRenders(); if (!charAnimations.size) releaseAnimationTicker(); } @@ -2153,7 +2164,7 @@ Enjoy!! :D } function getPaintOpsKey(state, layoutKey) { - return getPaintOpsGeometryKey(state, layoutKey) + '\u0004' + state.charMasksVersion; + return getPaintOpsGeometryKey(state, layoutKey) + '\u0004' + state.charMasksVersion + '\u0004' + state.charTransformsVersion; } const EMPTY_OVERRIDE = { @@ -2240,19 +2251,21 @@ Enjoy!! :D const originY = docH / 2; const geometryKey = getPaintOpsGeometryKey(state, layoutKey) + '\u0004' + docW + '\u0004' + docH; - const paintOpsKey = geometryKey + '\u0004' + state.charMasksVersion; + const paintOpsKey = geometryKey + '\u0004' + state.charMasksVersion + '\u0004' + state.charTransformsVersion; let paintOps = state.paintOps; let charBoxes = state.charBoxes; const geometryUnchanged = state.paintOpsGeometryKey === geometryKey && state.paintOpsLayout === layout && !!paintOps; + const transformsUnchanged = state.paintOpsTransformsVersion === state.charTransformsVersion; - if (geometryUnchanged && state.paintOpsKey !== paintOpsKey) { + if (geometryUnchanged && transformsUnchanged && state.paintOpsKey !== paintOpsKey) { for (let i = 0; i < paintOps.length; i++) { const op = paintOps[i]; op.mask = state.charMasks[op.charIndex] || null; } state.paintOpsKey = paintOpsKey; + state.paintOpsMasksVersion = state.charMasksVersion; const fingerprint = fingerprintMaskOps(paintOps, docW, docH); if (fingerprint === state.paintFingerprint && state.skinId !== null) { @@ -2368,6 +2381,8 @@ Enjoy!! :D state.paintOpsKey = paintOpsKey; state.paintOpsGeometryKey = geometryKey; state.paintOpsLayout = layout; + state.paintOpsTransformsVersion = state.charTransformsVersion; + state.paintOpsMasksVersion = state.charMasksVersion; state.charBoxes = charBoxes; state.charsByTag = charsByTag; state.drawableWidth = docW; @@ -5766,6 +5781,7 @@ self.onmessage = async (event) => { if (exists && o.x === x && o.y === y) return; o.x = x; o.y = y; + state.charTransformsVersion++; schedulePaint(util.target, state); } @@ -5779,6 +5795,7 @@ self.onmessage = async (event) => { if (exists && x === 0 && y === 0) return; o.x += x; o.y += y; + state.charTransformsVersion++; schedulePaint(util.target, state); } @@ -5791,6 +5808,7 @@ self.onmessage = async (event) => { const o = getCharOverride(state, idx); if (exists && o[axis] === value) return; o[axis] = value; + state.charTransformsVersion++; schedulePaint(util.target, state); } @@ -5802,6 +5820,7 @@ self.onmessage = async (event) => { const o = getCharOverride(state, idx); if (exists && o.rotation === rotation) return; o.rotation = rotation; + state.charTransformsVersion++; schedulePaint(util.target, state); } @@ -5813,6 +5832,7 @@ self.onmessage = async (event) => { const o = getCharOverride(state, idx); if (exists && rotation === 0) return; o.rotation += rotation; + state.charTransformsVersion++; schedulePaint(util.target, state); } @@ -5824,6 +5844,7 @@ self.onmessage = async (event) => { const o = getCharOverride(state, idx); if (exists && o.opacity === opacity) return; o.opacity = opacity; + state.charTransformsVersion++; schedulePaint(util.target, state); } @@ -5835,6 +5856,7 @@ self.onmessage = async (event) => { const o = getCharOverride(state, idx); if (exists && o.color === color) return; o.color = color; + state.charTransformsVersion++; schedulePaint(util.target, state); } @@ -5844,6 +5866,7 @@ self.onmessage = async (event) => { const o = state.charOverrides[idx]; if (!o || o.color === null) return; o.color = null; + state.charTransformsVersion++; schedulePaint(util.target, state); } @@ -5855,6 +5878,7 @@ self.onmessage = async (event) => { const o = getCharOverride(state, idx); if (exists && o.scale === scale) return; o.scale = scale; + state.charTransformsVersion++; schedulePaint(util.target, state); } @@ -5862,22 +5886,23 @@ self.onmessage = async (event) => { const target = util.target; const state = getState(target); const idx = Scratch.Cast.toNumber(args.INDEX) - 1; - const property = Scratch.Cast.toString(args.PROPERTY); + const property = Scratch.Cast.toString(args.PROPERTY).toLowerCase(); const validProps = ['x', 'y', 'rotation', 'opacity', 'scale']; if (!validProps.includes(property)) return; let targetValue = Scratch.Cast.toNumber(args.VALUE); if (property === 'opacity') targetValue = Math.max(0, Math.min(100, targetValue)) / 100; if (property === 'scale') targetValue = Math.max(0, targetValue) / 100; const duration = Math.max(0, Scratch.Cast.toNumber(args.SECS)); - const easingName = Scratch.Cast.toString(args.EASING); + const easingName = Scratch.Cast.toString(args.EASING).toLowerCase(); const easing = Object.prototype.hasOwnProperty.call(EasingMethods, easingName) ? easingName : 'linear'; - const direction = Scratch.Cast.toString(args.DIRECTION); + const direction = Scratch.Cast.toString(args.DIRECTION).toLowerCase(); const o = getCharOverride(state, idx); const key = target.id + '\u0001' + idx + '\u0001' + property; if (duration <= 0) { deleteCharAnimation(target.id, key); o[property] = targetValue; + state.charTransformsVersion++; schedulePaint(target, state); return; } @@ -5910,6 +5935,7 @@ self.onmessage = async (event) => { if (state.charOverrides[idx]) { delete state.charOverrides[idx]; state.charOverridesVersion++; + state.charTransformsVersion++; schedulePaint(util.target, state); } } @@ -5921,6 +5947,7 @@ self.onmessage = async (event) => { if (Object.keys(state.charOverrides).length) { state.charOverrides = {}; state.charOverridesVersion++; + state.charTransformsVersion++; schedulePaint(util.target, state); } } From ec76180e809ded6bc82c772b13b7553a57ad5f5c Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 20:10:45 -0300 Subject: [PATCH 20/21] fix a bug related to stopping the project --- static/extensions/Gen1x/iris-text.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index da6f2a166..2272e70e1 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -3982,6 +3982,9 @@ self.onmessage = async (event) => { clearTypingBox(state); state.paintDirty = true; state.paintFingerprint = null; + state.charMasks = {}; + state.charMasksVersion++; + state.charMaskGeometryVersion++; const costume = target.getCostumes()[target.currentCostume]; if (costume && runtime.renderer) { runtime.renderer.updateDrawableSkinId(target.drawableID, costume.skinId); @@ -6014,6 +6017,7 @@ self.onmessage = async (event) => { for (let idx = start; idx <= end; idx++) { const existing = state.charMasks[idx]; if (existing && existing.targetName === targetName && existing.costumeName === costumeName) { + paintNeeded = true; continue; } setCharMaskForIndex(state, idx, { From 3963200968ef15d55ef954962231affb267711d4 Mon Sep 17 00:00:00 2001 From: Gen1xLol Date: Tue, 25 Aug 2026 22:05:49 -0300 Subject: [PATCH 21/21] fix SP issues & show text delays hopefully --- static/extensions/Gen1x/iris-text.js | 47 ++++++++++------------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/static/extensions/Gen1x/iris-text.js b/static/extensions/Gen1x/iris-text.js index 2272e70e1..4eb89ddf3 100644 --- a/static/extensions/Gen1x/iris-text.js +++ b/static/extensions/Gen1x/iris-text.js @@ -2146,7 +2146,9 @@ Enjoy!! :D } function scheduleTextRender(target) { - scheduleRender(target); + pendingRenderTargets.delete(target); + const state = getState(target); + if (state.visible) renderTarget(target); } function schedulePaint(target, state) { @@ -2401,37 +2403,20 @@ Enjoy!! :D } } - state.renderInFlight = true; state.renderQueued = false; - const paintedPromise = compositeGlyphsAndPush(target, state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); + compositeGlyphsAndPush(target, state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); state.paintDirty = false; - state.renderPromise = Promise.resolve(paintedPromise).then(() => { - state.hasPaintedOnce = true; - }).finally(() => { - state.renderInFlight = false; - state.renderPromise = null; - if (state.renderQueued) { - state.renderQueued = false; - scheduleRender(target); - } - }); - return state.renderPromise; + state.hasPaintedOnce = true; + state.renderInFlight = false; + state.renderPromise = null; + if (state.renderQueued) { + state.renderQueued = false; + scheduleRender(target); + } + return Promise.resolve(); } function compositeGlyphsAndPush(target, state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY) { - if (!renderWorkerFailed && supportsRenderWorker()) { - const workerPromise = compositeGlyphsViaWorker(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); - if (workerPromise) { - return workerPromise.then((result) => { - pushImageBitmapToDrawable(target, result.bitmap, result.pixelW, result.pixelH, docW, docH); - }).catch((err) => { - renderWorkerFailed = true; - console.log('[Iris Text] Render worker request failed (' + (err && err.message ? err.message : err) + ') — falling back to main-thread rendering.'); - const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); - pushCanvasToDrawable(target, canvas, docW, docH); - }); - } - } const canvas = compositeGlyphsToCanvas(state, paintOps, docW, docH, effectiveMaxWidth, totalHeight, originX, originY); pushCanvasToDrawable(target, canvas, docW, docH); return Promise.resolve(); @@ -2527,10 +2512,10 @@ Enjoy!! :D const DEST_SCALE = GLYPH_OVERSAMPLE; const WORKER_SOURCE = ` -const WIPE_DIRECTION_BOTTOM_UP = 'bottom-up'; -const WIPE_DIRECTION_LEFT_RIGHT = 'left-right'; -const WIPE_DIRECTION_UP_DOWN = 'up-down'; -const WIPE_DIRECTION_RIGHT_LEFT = 'right-left'; +const WIPE_DIRECTION_BOTTOM_UP = '${WIPE_DIRECTION_BOTTOM_UP}'; +const WIPE_DIRECTION_LEFT_RIGHT = '${WIPE_DIRECTION_LEFT_RIGHT}'; +const WIPE_DIRECTION_UP_DOWN = '${WIPE_DIRECTION_UP_DOWN}'; +const WIPE_DIRECTION_RIGHT_LEFT = '${WIPE_DIRECTION_RIGHT_LEFT}'; const DEST_SCALE = 3; const TINT_CACHE_LIMIT = 512;