写一个Typecho极简图片预览插件
想找一个图片点击放大的插件,看了一圈,感觉都太重了,我只想要实现一个点击可以全屏预览、缩放就可以了,工作量很少,自己写一个吧
前端
确定需求
图片缩放肯定不能所有全站图片都缩放,比如logo,评论人头像等。所以要指定区间,我用的主题是自己修改过的官方主题,看过代码能看到,文章内容是被这个div包起来的
所以需求就是,被这个div包裹的所有图片元素,点击之后会增加全屏蒙版,然后把图片贴到蒙版上,并且图片可以缩放、拖动,再点击蒙版进行关闭,图片加载过程中要有loading,并且加载后就缓存起来,重复查看不要再请求网络
代码
直接CHatGPT,这种简单的代码,问问GPT又快又好的给我写好了
<div class="fullscreen-overlay" id="overlay">
<div class="loading">Loading...</div> <!-- Loading text -->
<img src="" alt="Fullscreen Image" id="fullscreenImage">
</div>
img {
max-width: 100%;
cursor: pointer;
transition: transform 0.1s ease;
}
/* Fullscreen modal with black overlay */
.fullscreen-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease;
}
/* When active, overlay is visible */
.fullscreen-overlay.active {
visibility: visible;
opacity: 1;
}
/* Disable scrolling */
body.no-scroll {
overflow: hidden;
}
/* Fullscreen image styling */
.fullscreen-overlay img {
max-width: 100%;
max-height: 100%;
cursor: grab;
transform: scale(1) translate(0, 0);
transition: transform 0.1s ease;
display: none; /* Initially hide the image until it's loaded */
}
.fullscreen-overlay img:active {
cursor: grabbing;
}
/* Loading spinner or text */
.loading {
color: white;
font-size: 24px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: none;
}
/* Show loading when image is not ready */
.fullscreen-overlay.loading-active .loading {
display: block;
}
const postContent = document.querySelector('.post-content');
const overlay = document.getElementById('overlay');
const fullscreenImage = document.getElementById('fullscreenImage');
const loadingText = overlay.querySelector('.loading');
let scale = 1;
let isDragging = false;
let startX, startY, currentX = 0, currentY = 0;
let cachedImages = {}; // Cache for storing loaded images
// Add event listeners to all images within the .post-content div
const images = postContent.querySelectorAll('img');
images.forEach(image => {
image.addEventListener('click', () => {
const imgSrc = image.src;
// Display loading text
overlay.classList.add('loading-active');
loadingText.style.display = 'block';
// Check if image is already cached
if (!cachedImages[imgSrc]) {
// Cache the image if it's not cached yet
fullscreenImage.src = imgSrc;
cachedImages[imgSrc] = true;
// Show overlay but keep image hidden until it's fully loaded
overlay.classList.add('active');
document.body.classList.add('no-scroll');
// When the image loads, hide the loading text and display the image
fullscreenImage.onload = () => {
overlay.classList.remove('loading-active');
loadingText.style.display = 'none';
fullscreenImage.style.display = 'block';
resetImageTransform();
};
} else {
// If image is cached, show it immediately
overlay.classList.add('active');
document.body.classList.add('no-scroll');
overlay.classList.remove('loading-active');
loadingText.style.display = 'none';
fullscreenImage.style.display = 'block';
fullscreenImage.src = imgSrc;
resetImageTransform();
}
});
});
// Hide fullscreen when clicking on overlay (outside the image)
overlay.addEventListener('click', (event) => {
if (event.target === overlay) {
overlay.classList.remove('active');
scale = 1;
fullscreenImage.style.transform = `scale(${scale}) translate(0, 0)`;
fullscreenImage.style.display = 'none'; // Hide image when closing
// Re-enable body scrolling
document.body.classList.remove('no-scroll');
}
});
// Reset the image scale and position when opened
function resetImageTransform() {
scale = 1;
currentX = 0;
currentY = 0;
fullscreenImage.style.transform = `scale(${scale}) translate(0, 0)`;
}
// Zoom in/out on mouse wheel
overlay.addEventListener('wheel', (event) => {
event.preventDefault();
const zoomSpeed = 0.1;
if (event.deltaY < 0) {
// Zoom in
scale += zoomSpeed;
} else {
// Zoom out
scale = Math.max(1, scale - zoomSpeed); // Prevent zooming out beyond original size
}
fullscreenImage.style.transform = `scale(${scale}) translate(${currentX}px, ${currentY}px)`;
});
// Dragging logic
fullscreenImage.addEventListener('mousedown', (event) => {
isDragging = true;
startX = event.clientX - currentX;
startY = event.clientY - currentY;
fullscreenImage.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (event) => {
if (isDragging) {
currentX = event.clientX - startX;
currentY = event.clientY - startY;
fullscreenImage.style.transform = `scale(${scale}) translate(${currentX}px, ${currentY}px)`;
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
fullscreenImage.style.cursor = 'grab';
});
// Prevent text/image selection while dragging
fullscreenImage.addEventListener('dragstart', (event) => {
event.preventDefault();
});
直接使用
其实这里就可以直接用了,把对应的代码粘贴到对应的位置就行了,没啥好说的。但这样直接就硬写入到主题中了,所以写个插件
插件
插件基于最新版的typecho 1.3.0
编写,插件名就叫做SimplePicView
吧
开发步骤
空插件
先下载代码https://github.com/typecho/typecho/releases/download/v1.3.0-alpha/typecho.zip并解压,然后用编辑器打开
在找到usr/plugins目录,新建一个目录SimplePicView,然后在SimplePicView目录内新建一个Plugin.php
以下是内容
<?php
namespace TypechoPlugin\SimplePicView;
use Typecho\Plugin\PluginInterface;
use Typecho\Widget\Helper\Form;
if (!defined('__TYPECHO_ROOT_DIR__')) {
exit;
}
/**
* SimplePicView
*
* @package SimplePicView
* @author Evlan
* @version 1.0.0
* @link https://evlan.cc
*/
class Plugin implements PluginInterface
{
/**
* 激活插件方法,如果激活失败,直接抛出异常
*/
public static function activate()
{
}
/**
* 禁用插件方法,如果禁用失败,直接抛出异常
*/
public static function deactivate()
{
}
/**
* 获取插件配置面板
*
* @param Form $form 配置面板
*/
public static function config(Form $form)
{
}
/**
* 个人用户的配置面板
*
* @param Form $form
*/
public static function personalConfig(Form $form)
{
}
}
这样一个空插件就好了
填充代码
下面是我的思路,顺藤摸瓜。
首先我们要填充css样式代码,在页面主题头部可以看到有这个方法执行,那就可以在这里输出
最后在var/Widget/Archive.php找到这个header()方法,发现在最末尾有这个插件支持,那我们就接着从这里入手
这个pluginHandle()方法是继承var/Typecho/Widget.php的,他把自己的名字传进去,获取一个插件。代码如下
/**
* 获取对象插件句柄
*
* @return Plugin
*/
public static function pluginHandle(): Plugin
{
return Plugin::factory(static::class);
}
因为是继承var/Typecho/Widget.php的方法,var/Widget/Archive.php
本身并没有实现,所以这里要用static::class才能获取到自己的名字。如果用self获取到的就是var/Typecho/Widget.php的名字
这里简单说一下static和self的区别,self是定义这个方法的类,而static是当前类,比如下面这个例子
class A
{
public static function echo(): void
{
echo 'A';
}
public static function do(): void
{
self::echo();
}
public static function do2()
{
static::echo();
}
}
class B extends A
{
public static function echo(): void
{
echo 'B';
}
}
B::do();//输出A
B::do2();//输出B
再深入Plugin::factory发现是一个单例,就是有值就直接返回,没有值就new一个自己设置进去然后再返回。
/**
* 获取实例化插件对象
*
* @param string $handle 插件
* @return Plugin
*/
public static function factory(string $handle): Plugin
{
return self::$instances[$handle] ?? (self::$instances[$handle] = new self($handle));
}
再看这个new方法,解释放图中
/**
* 插件初始化
*
* @param string $handle 插件
*/
public function __construct(string $handle)
{
//'Typecho_Plugin_Interface' => '\Typecho\Plugin\PluginInterface'把名字做一个转换
//根据上下文,传入的应该是\Widget\Archive。看__TYPECHO_CLASS_ALIASES__这个定义,此处没做处理
if (defined('__TYPECHO_CLASS_ALIASES__')) {
$alias = array_search('\\' . ltrim($handle, '\\'), __TYPECHO_CLASS_ALIASES__);
$handle = $alias ?: $handle;
}
//这里再进行一个转换 出来是 Widget_Archive
$this->handle = Common::nativeClassName($handle);
}
所以这个self::pluginHandle()方法就是从Plugin类中获取一个属于var/Widget/Archive.php类的单例Plugin,所以返回的是一个Plugin(插件)
后面调用了call('header', $header, $this),我们看Plugin中的call方法,代码如下
/**
* 回调处理函数
*
* @param string $component 当前组件
* @param array $args 参数
* @return mixed
*/
public function call(string $component, ...$args)
{
//根据上下文,$component = 'Widget_Archive:header'
$component = $this->handle . ':' . $component;
$last = count($args);
$args[$last] = $last > 0 ? $args[0] : false;
if (isset(self::$plugin['handles'][$component])) {
$args[$last] = null;
$this->signal = true;
foreach (self::$plugin['handles'][$component] as $callback) {
$args[$last] = call_user_func_array($callback, $args);
}
}
return $args[$last];
}
可以看到最终调用了Plugin::$plugin['handles']['Widget_Archive:header']
这个数组里的回调,也就是我们在的插件把输出css代码的语句放进去就好了。
找一下是在这里赋值的
/**
* 设置回调函数
*
* @param string $component 当前组件
* @param callable $value 回调函数
*/
public function __set(string $component, callable $value)
{
$weight = 0;
if (strpos($component, '_') > 0) {
$parts = explode('_', $component, 2);
[$component, $weight] = $parts;
$weight = intval($weight) - 10;
}
$component = $this->handle . ':' . $component;
if (!isset(self::$plugin['handles'][$component])) {
self::$plugin['handles'][$component] = [];
}
if (!isset(self::$tmp['handles'][$component])) {
self::$tmp['handles'][$component] = [];
}
foreach (self::$plugin['handles'][$component] as $key => $val) {
$key = floatval($key);
if ($weight > $key) {
break;
} elseif ($weight == $key) {
$weight += 0.001;
}
}
self::$plugin['handles'][$component][strval($weight)] = $value;
self::$tmp['handles'][$component][] = $value;
ksort(self::$plugin['handles'][$component], SORT_NUMERIC);
}
是一个魔术方法,直接给Plugin对象赋值就能走到这里,所以执行这个代码就能走到这里,调用代码是\Typecho\Plugin::factory(\Widget\Archive::class)->header = 回调function
,看一下HelloWorld那个插件,在激活方法中正好和我们写的这个差不多。
再往下找就没找到什么东西了,换个方向从激活插件的方法入手找。
首先找到这个方法,看哪里调用了
最终找到这里var/Widget/Plugins/Edit.php
执行完激活方法之后,执行这个Plugin::activate($pluginName);
内容如下
/**
* 启用插件
*
* @param string $pluginName 插件名称
*/
public static function activate(string $pluginName)
{
self::$plugin['activated'][$pluginName] = self::$tmp;
self::$tmp = [];
}
就是把这个self::$tmp;静态变量存到self::$plugin['activated'][$pluginName]
中,
再往下这个
$this->update(
['value' => json_encode(Plugin::export())],
$this->db->sql()->where('name = ?', 'plugins')
);
一看就是把self::$plugin转成json格式存到数据库中,所以激活的时候self::$tmp
变量很关键,而恰好,上面往后找不到后续的__set方法中就有给这个self::$tmp赋值...
破案了,只要在激活插件的方法里给\Widget\Archive::class的Plugin
附上我们的函数,他激活插件的时候就会执行,然后在__set方法中就会赋值self::$tmp,接下来就数据入库,激活插件。
所以在SimplePicView/Plugin.php的activate方法写下赋值插件函数回调
/**
* 激活插件方法,如果激活失败,直接抛出异常
*/
public static function activate()
{
//因为要json入库数据库,所以回调要这么写,不能直接写方法
\Typecho\Plugin::factory(\Widget\Archive::class)->header = [self::class, 'setHeader'];
}
public static function setHeader()
{
echo 666;
}
激活,然后刷新首页看看。
成功。以此类推,找到对应的切入点html、js也切进去
使用
最终结果usr/plugins/SimplePicView/Plugin.php:
<?php
namespace TypechoPlugin\SimplePicView;
use Typecho\Plugin\PluginInterface;
use Typecho\Widget\Helper\Form;
if (!defined('__TYPECHO_ROOT_DIR__')) {
exit;
}
/**
* 一个简单的图片预览插件,支持全屏预览。只对默认主题进行了适配。有问题可看链接自定义修改
*
* @package SimplePicView
* @author Evlan
* @version 1.0.0
* @link https://evlan.cc/archives/typecho-plugins-pic-view.html
*/
class Plugin implements PluginInterface
{
/**
* 激活插件方法,如果激活失败,直接抛出异常
*/
public static function activate()
{
\Typecho\Plugin::factory(\Widget\Archive::class)->header = [self::class, 'setHeader'];
\Typecho\Plugin::factory(\Widget\Archive::class)->footer = [self::class, 'setFooter'];
}
public static function setHeader(): void
{
echo <<<'EOL'
<style>
img {
max-width: 100%;
cursor: pointer;
transition: transform 0.1s ease;
}
/* Fullscreen modal with black overlay */
.fullscreen-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease;
}
/* When active, overlay is visible */
.fullscreen-overlay.active {
visibility: visible;
opacity: 1;
}
/* Disable scrolling */
body.no-scroll {
overflow: hidden;
}
/* Fullscreen image styling */
.fullscreen-overlay img {
max-width: 100%;
max-height: 100%;
cursor: grab;
transform: scale(1) translate(0, 0);
transition: transform 0.1s ease;
display: none; /* Initially hide the image until it's loaded */
}
.fullscreen-overlay img:active {
cursor: grabbing;
}
/* Loading spinner or text */
.loading {
color: white;
font-size: 24px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: none;
}
/* Show loading when image is not ready */
.fullscreen-overlay.loading-active .loading {
display: block;
}
</style>
EOL;
}
public static function setFooter(): void
{
echo <<<'EOL'
<div class="fullscreen-overlay" id="overlay">
<div class="loading">Loading...</div> <!-- Loading text -->
<img src="" alt="Fullscreen Image" id="fullscreenImage">
</div>
<script>
const postContent = document.querySelector('.post-content');
const overlay = document.getElementById('overlay');
const fullscreenImage = document.getElementById('fullscreenImage');
const loadingText = overlay.querySelector('.loading');
let scale = 1;
let isDragging = false;
let startX, startY, currentX = 0, currentY = 0;
let cachedImages = {}; // Cache for storing loaded images
// Add event listeners to all images within the .post-content div
const images = postContent.querySelectorAll('img');
images.forEach(image => {
image.addEventListener('click', () => {
const imgSrc = image.src;
// Display loading text
overlay.classList.add('loading-active');
loadingText.style.display = 'block';
// Check if image is already cached
if (!cachedImages[imgSrc]) {
// Cache the image if it's not cached yet
fullscreenImage.src = imgSrc;
cachedImages[imgSrc] = true;
// Show overlay but keep image hidden until it's fully loaded
overlay.classList.add('active');
document.body.classList.add('no-scroll');
// When the image loads, hide the loading text and display the image
fullscreenImage.onload = () => {
overlay.classList.remove('loading-active');
loadingText.style.display = 'none';
fullscreenImage.style.display = 'block';
resetImageTransform();
};
} else {
// If image is cached, show it immediately
overlay.classList.add('active');
document.body.classList.add('no-scroll');
overlay.classList.remove('loading-active');
loadingText.style.display = 'none';
fullscreenImage.style.display = 'block';
fullscreenImage.src = imgSrc;
resetImageTransform();
}
});
});
// Hide fullscreen when clicking on overlay (outside the image)
overlay.addEventListener('click', (event) => {
if (event.target === overlay) {
overlay.classList.remove('active');
scale = 1;
fullscreenImage.style.transform = `scale(${scale}) translate(0, 0)`;
fullscreenImage.style.display = 'none'; // Hide image when closing
// Re-enable body scrolling
document.body.classList.remove('no-scroll');
}
});
// Reset the image scale and position when opened
function resetImageTransform() {
scale = 1;
currentX = 0;
currentY = 0;
fullscreenImage.style.transform = `scale(${scale}) translate(0, 0)`;
}
// Zoom in/out on mouse wheel
overlay.addEventListener('wheel', (event) => {
event.preventDefault();
const zoomSpeed = 0.1;
if (event.deltaY < 0) {
// Zoom in
scale += zoomSpeed;
} else {
// Zoom out
scale = Math.max(1, scale - zoomSpeed); // Prevent zooming out beyond original size
}
fullscreenImage.style.transform = `scale(${scale}) translate(${currentX}px, ${currentY}px)`;
});
// Dragging logic
fullscreenImage.addEventListener('mousedown', (event) => {
isDragging = true;
startX = event.clientX - currentX;
startY = event.clientY - currentY;
fullscreenImage.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (event) => {
if (isDragging) {
currentX = event.clientX - startX;
currentY = event.clientY - startY;
fullscreenImage.style.transform = `scale(${scale}) translate(${currentX}px, ${currentY}px)`;
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
fullscreenImage.style.cursor = 'grab';
});
// Prevent text/image selection while dragging
fullscreenImage.addEventListener('dragstart', (event) => {
event.preventDefault();
});
</script>
EOL;
}
/**
* 禁用插件方法,如果禁用失败,直接抛出异常
*/
public static function deactivate()
{
}
/**
* 获取插件配置面板
*
* @param Form $form 配置面板
*/
public static function config(Form $form)
{
}
/**
* 个人用户的配置面板
*
* @param Form $form
*/
public static function personalConfig(Form $form)
{
}
}
1.手动安装
在插件目录新建SimplePicView目录,然后在里面建一个Plugin.php文件,把上面的代码贴进去,然后后台激活即可
2.安装包
下载之后在插件目录解压即可https://evlan.cc/download/file/TypechoSimplePicView.zip